@meridiona/meridian-darwin-arm64 1.64.0 → 1.65.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.
Files changed (87) hide show
  1. package/VERSION +1 -1
  2. package/bin/meridian +0 -0
  3. package/bin/meridian-tray +0 -0
  4. package/package.json +1 -1
  5. package/scripts/meridian-cli.sh +19 -18
  6. package/services/README.md +6 -12
  7. package/services/agents/README.md +10 -16
  8. package/services/agents/_state.py +34 -0
  9. package/services/agents/agno_viewer.py +60 -0
  10. package/services/agents/config.py +8 -0
  11. package/services/agents/mlx_classifier.py +195 -0
  12. package/services/agents/observability.py +48 -2
  13. package/services/agents/pm_worklog_update/config.py +3 -3
  14. package/services/agents/prompts/__init__.py +0 -0
  15. package/services/agents/prompts/activity_report.py +71 -0
  16. package/services/agents/prompts/coding_agent_session_summary.py +28 -0
  17. package/services/agents/reranker.py +118 -0
  18. package/services/agents/routes/__init__.py +6 -0
  19. package/services/agents/routes/activity.py +162 -0
  20. package/services/agents/routes/chat.py +175 -0
  21. package/services/agents/routes/distill.py +103 -0
  22. package/services/agents/routes/health.py +41 -0
  23. package/services/agents/routes/prefetch.py +138 -0
  24. package/services/agents/routes/rerank.py +79 -0
  25. package/services/agents/routes/summarise.py +131 -0
  26. package/services/agents/routes/worklog.py +103 -0
  27. package/services/agents/server.py +58 -1050
  28. package/services/agents/session_distiller.py +537 -0
  29. package/services/agents/worklog_pipeline/__init__.py +11 -0
  30. package/services/agents/worklog_pipeline/agent_io.py +75 -0
  31. package/services/agents/worklog_pipeline/db.py +277 -0
  32. package/services/agents/worklog_pipeline/match.py +145 -0
  33. package/services/agents/worklog_pipeline/models.py +98 -0
  34. package/services/agents/worklog_pipeline/pipeline.py +418 -0
  35. package/services/agents/worklog_pipeline/prompts/__init__.py +1 -0
  36. package/services/agents/worklog_pipeline/prompts/match_tasks.py +38 -0
  37. package/services/agents/worklog_pipeline/prompts/propose_ticket.py +19 -0
  38. package/services/agents/worklog_pipeline/prompts/worklog.py +20 -0
  39. package/services/agents/worklog_pipeline/workflow.py +217 -0
  40. package/services/agents/worklog_pipeline/worklog.py +41 -0
  41. package/services/pyproject.toml +1 -1
  42. package/services/scripts/com.meridiona.mlx-server.plist +3 -3
  43. package/services/scripts/count_classifier_tokens.py +1 -1
  44. package/services/scripts/install-mlx-server-daemon.sh +3 -3
  45. package/services/tests/evals/README.md +7 -7
  46. package/services/tests/evals/configs/baseline_a_meridian.json +3 -3
  47. package/services/tests/evals/configs/baseline_b_generic.json +3 -3
  48. package/services/tests/evals/configs/extract_then_classify_b_generic.json +3 -3
  49. package/services/uv.lock +1 -1
  50. package/services/agents/_parser.py +0 -187
  51. package/services/agents/_prompts.py +0 -238
  52. package/services/agents/_system_context.py +0 -42
  53. package/services/agents/llm_selector.py +0 -728
  54. package/services/agents/run_task_linker_mlx.py +0 -1873
  55. package/services/agents/tests/test_llm_selector.py +0 -183
  56. package/services/agents/tests/test_parser.py +0 -134
  57. package/services/agents/tests/test_run_task_linker_mlx.py +0 -1127
  58. package/services/observability/dashboards/classifier-debug.json +0 -187
  59. package/services/observability/dashboards/pm-worklog-debug.json +0 -140
  60. package/services/skills/activity/task-classifier/FEEDBACK.json +0 -2656
  61. package/services/skills/activity/task-classifier/SKILL.applefm.md +0 -77
  62. package/services/skills/activity/task-classifier/SKILL.md +0 -136
  63. package/services/tests/conftest.py +0 -7
  64. package/services/tests/evals/build_dataset.py +0 -178
  65. package/services/tests/evals/build_real_goldens.py +0 -180
  66. package/services/tests/evals/classify_session.py +0 -126
  67. package/services/tests/evals/compare_pipeline.py +0 -262
  68. package/services/tests/evals/conftest.py +0 -5
  69. package/services/tests/evals/data/labels/real_2026-05-28.json +0 -1172
  70. package/services/tests/evals/data/labels/real_curated.json +0 -166
  71. package/services/tests/evals/data/labels/real_curated_holdout.json +0 -97
  72. package/services/tests/evals/data/labels/real_curated_holdout2.json +0 -64
  73. package/services/tests/evals/data/seeds/sessions_a_meridian.json +0 -1757
  74. package/services/tests/evals/data/seeds/sessions_b_generic.json +0 -1319
  75. package/services/tests/evals/data/seeds/tickets_generic.json +0 -72
  76. package/services/tests/evals/data/seeds/tickets_meridian.json +0 -87
  77. package/services/tests/evals/eval_classifier.py +0 -660
  78. package/services/tests/evals/metrics.py +0 -260
  79. package/services/tests/evals/render_seeds.py +0 -143
  80. package/services/tests/evals/strategies.py +0 -655
  81. package/services/tests/evals/test_classifier.py +0 -343
  82. package/services/tests/evals/test_model_sweep.py +0 -197
  83. package/services/tests/test_continuity_context.py +0 -202
  84. package/services/tests/test_fetch_pm_tasks.py +0 -120
  85. package/services/tests/test_format_candidates.py +0 -45
  86. package/services/tests/test_llm_selector.py +0 -223
  87. package/services/tests/test_prompt_cache_equivalence.py +0 -97
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.64.0
1
+ 1.65.0
package/bin/meridian CHANGED
Binary file
package/bin/meridian-tray CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meridiona/meridian-darwin-arm64",
3
- "version": "1.64.0",
3
+ "version": "1.65.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": {
@@ -11,12 +11,12 @@ REPO_ROOT="$(cd "$(dirname "$SELF")/.." && pwd)"
11
11
  # --- constants ---
12
12
  LABEL_SCREENPIPE="com.meridiona.screenpipe"
13
13
  LABEL_DAEMON="com.meridiona.daemon"
14
- LABEL_UI="com.meridiona.ui" # retired (dashboard now embedded in the tray) — kept only to boot out a leftover legacy agent
14
+ LABEL_UI="com.meridiona.ui"
15
15
  LABEL_MLX="com.meridiona.mlx-server"
16
- # Jira worklogs and coding-agent ingest run inside the Rust daemon, and the
17
- # dashboard is embedded in the tray binary — no separate UI launchd agent. Only
18
- # these three production services are managed.
19
- readonly LABELS=("${LABEL_SCREENPIPE}" "${LABEL_DAEMON}" "${LABEL_MLX}")
16
+ # Capture runs in-process in the tray (no screenpipe launchd agent since Bucket-2).
17
+ # Jira worklogs and coding-agent ingest run inside the Rust daemon — no
18
+ # separate launchd agents. Only these two are managed.
19
+ readonly LABELS=("${LABEL_DAEMON}" "${LABEL_MLX}")
20
20
  GUI_TARGET="gui/$(id -u)"
21
21
  LAUNCH_AGENTS="${HOME}/Library/LaunchAgents"
22
22
  LOG_DIR="${HOME}/.meridian/logs"
@@ -41,7 +41,7 @@ Commands:
41
41
  restart Stop, wait 1s, start
42
42
  status Show running state of all daemons
43
43
  logs [target] Tail log files
44
- target: daemon|daemon-error|screenpipe|screenpipe-error|ui|ui-error|mlx-server|mlx-server-error
44
+ target: daemon|daemon-error|mlx-server|mlx-server-error|tray|tray-error
45
45
  -f Follow (stream)
46
46
  -n N Last N lines (default 100)
47
47
  doctor Run environment health checks (includes pipeline smoke)
@@ -180,7 +180,12 @@ cmd_logs() {
180
180
  ui-error) log_file="${LOG_DIR}/ui-error.log" ;;
181
181
  mlx-server) log_file="${LOG_DIR}/mlx-server.log" ;;
182
182
  mlx-server-error) log_file="${LOG_DIR}/mlx-server-error.log" ;;
183
- *) err "unknown log target: ${target} (daemon|daemon-error|screenpipe|screenpipe-error|ui|ui-error|mlx-server|mlx-server-error)"; exit 1 ;;
183
+ tray) log_file="${LOG_DIR}/tray.log" ;;
184
+ tray-error) log_file="${LOG_DIR}/tray-error.log" ;;
185
+ # screenpipe/ui are retired post-v1.64.0 (capture is in-process in the
186
+ # tray; the dashboard is embedded) — kept here so old log files remain
187
+ # tailable, but `tray`/`tray-error` are the live targets now.
188
+ *) err "unknown log target: ${target} (daemon|daemon-error|mlx-server|mlx-server-error|tray|tray-error)"; exit 1 ;;
184
189
  esac
185
190
 
186
191
  if [[ ! -f "$log_file" ]]; then
@@ -269,11 +274,12 @@ _doctor_fallback() {
269
274
  printf " ════════════════════════════════════════════════════════\n"
270
275
  _group "system"
271
276
  _row "$([[ "$(uname -s)" == "Darwin" ]] && echo ok || echo fail)" "macOS" ""
272
- _row "$([[ -f "${HOME}/.meridian/.env" || -f "${REPO_ROOT}/.env" ]] && echo ok || echo fail)" "config (.env)" ""
277
+ _row "$([[ -f "${REPO_ROOT}/.env" ]] && echo ok || echo fail)" "config (.env)" ""
273
278
  _group "services (plists)"
274
279
  _plist_row "$LABEL_DAEMON" "daemon plist"
275
280
  _plist_row "$LABEL_SCREENPIPE" "screenpipe plist"
276
281
  _plist_row "$LABEL_MLX" "mlx plist"
282
+ _plist_row "$LABEL_UI" "ui plist"
277
283
  _group "builds"
278
284
  _row "$([[ -f "${REPO_ROOT}/packages/meridian-mcp/dist/index.js" ]] && echo ok || echo fail)" "mcp built" ""
279
285
  _row "$([[ -d "${REPO_ROOT}/ui/.next" ]] && echo ok || echo fail)" "ui built" ""
@@ -288,9 +294,7 @@ _doctor_fallback() {
288
294
  # (no flag) full run: classification + worklog synthesis
289
295
 
290
296
  _smoke_read_env() {
291
- local key="$1"
292
- local env_file="${HOME}/.meridian/.env"
293
- [[ -f "$env_file" ]] || env_file="${REPO_ROOT}/.env"
297
+ local key="$1" env_file="${REPO_ROOT}/.env"
294
298
  [[ -f "$env_file" ]] || return 0
295
299
  grep -E "^${key}=" "$env_file" 2>/dev/null | tail -1 | cut -d= -f2- || true
296
300
  }
@@ -454,10 +458,9 @@ cmd_config() {
454
458
  err "usage: meridian config edit"
455
459
  exit 1
456
460
  fi
457
- local env_file="${HOME}/.meridian/.env"
458
- [[ -f "$env_file" ]] || env_file="${REPO_ROOT}/.env"
461
+ local env_file="${REPO_ROOT}/.env"
459
462
  if [[ ! -f "$env_file" ]]; then
460
- err "~/.meridian/.env not found — run the installer first"
463
+ err "${env_file} not found — run ./install.sh first"
461
464
  exit 1
462
465
  fi
463
466
  "${EDITOR:-nano}" "$env_file"
@@ -497,9 +500,7 @@ cmd_uninstall() {
497
500
  set +e
498
501
 
499
502
  # 1. Stop and remove all launchd agents
500
- # Legacy UI server (retired — dashboard is now in the tray): boot out + remove inline.
501
- launchctl bootout "${GUI_TARGET}/${LABEL_UI}" 2>/dev/null || true
502
- rm -f "${HOME}/Library/LaunchAgents/${LABEL_UI}.plist" 2>/dev/null || true
503
+ bash "${REPO_ROOT}/scripts/uninstall-ui-daemon.sh" 2>/dev/null
503
504
  bash "${REPO_ROOT}/services/scripts/uninstall-mlx-server-daemon.sh" 2>/dev/null
504
505
  bash "${REPO_ROOT}/scripts/uninstall-daemon.sh" 2>/dev/null
505
506
  bash "${REPO_ROOT}/scripts/uninstall-screenpipe-daemon.sh" 2>/dev/null
@@ -568,7 +569,7 @@ PYEOF
568
569
  "models--mlx-community--phi-4-4bit"
569
570
  "models--mlx-community--DeepSeek-R1-Distill-Qwen-14B-4bit"
570
571
  "models--mlx-community--gemma-3-12b-it-qat-4bit"
571
- "models--mlx-community--Qwen3.5-9B-OptiQ-4bit"
572
+ "models--mlx-community--Qwen3.5-2B-OptiQ-4bit"
572
573
  "models--mlx-community--Qwen3.5-4B-MLX-4bit"
573
574
  "models--mlx-community--Llama-3.2-3B-Instruct-4bit"
574
575
  )
@@ -4,7 +4,7 @@ The model layer that runs alongside the Rust daemon. A persistent MLX inference
4
4
 
5
5
  The Rust daemon owns all DDL; this service only does SELECT/INSERT/UPDATE on its agent-side tables.
6
6
 
7
- > **Note:** the coding-agent indexer + summariser, and the pm-worklog (Stage 4) driver, all run **inside the Rust daemon** (`src/coding_agent_session_ingest/`, `src/pm_worklog/`). This service is the model layer: `agents/server.py` is the persistent MLX server, which serves `/classify_sessions` (classification), `/summarise` (the summariser's fallback), and `/synthesise_worklog` (the agno worklog synthesiser). The Rust daemon drives all three over HTTP.
7
+ > **Note:** the coding-agent indexer + summariser, and the pm-worklog (Stage 4) driver, all run **inside the Rust daemon** (`src/coding_agent_session_ingest/`, `src/pm_worklog/`). This service is the model layer: `agents/server.py` is the persistent MLX server, which serves `/summarise` (the summariser's fallback) and `/synthesise_worklog` (the agno worklog synthesiser). The Rust daemon drives both over HTTP.
8
8
 
9
9
  For the deep technical reference (classification logic, schema, recipes), see [`agents/README.md`](agents/README.md).
10
10
 
@@ -15,14 +15,8 @@ For the deep technical reference (classification logic, schema, recipes), see [`
15
15
  ```
16
16
  app_sessions row (Rust ETL writes it)
17
17
 
18
- │ Rust daemon calls POST /classify_sessions
19
- ▼ on the persistent MLX server
20
- ┌──────────────────────────────────────────────┐
21
- │ MLX inference server (FastAPI, port 7823) │ Apple Silicon only
22
- │ model: mlx-community/Qwen3.5-9B-OptiQ-4bit│
23
- │ loaded once at startup, served until killed│
24
- │ returns: task_key, session_type, confidence│
25
- └──────────────────────────────────────────────┘
18
+
19
+ Rust daemon classifies sessions (src/intelligence/task_linker/)
26
20
 
27
21
 
28
22
  Rust writes ticket_links + session_dimensions → meridian.db
@@ -50,7 +44,7 @@ The `hermes-agent` package is fetched from GitHub at the pinned tag; an internet
50
44
 
51
45
  ## MLX server
52
46
 
53
- The Rust daemon calls the MLX server for every classification. It must be running before the daemon starts.
47
+ The Rust daemon calls the MLX server for summarisation and worklog synthesis. It must be running before the daemon starts.
54
48
 
55
49
  ### Install as a launchd daemon (recommended)
56
50
 
@@ -74,7 +68,7 @@ RUST_LOG=meridian=debug CLASSIFIER_BACKEND=mlx cargo run --bin meridian
74
68
  python -m agents.server --backend mlx --port 7823
75
69
  ```
76
70
 
77
- The model (`Qwen3.5-9B-OptiQ-4bit`) is downloaded from Hugging Face on first run (~4 GB). Subsequent starts load from local cache in ~5 s.
71
+ The model (`Qwen3.5-2B-OptiQ-4bit`) is downloaded from Hugging Face on first run. Subsequent starts load from local cache in ~5 s.
78
72
 
79
73
  ---
80
74
 
@@ -96,7 +90,7 @@ Additional variables are documented in [`agents/README.md`](agents/README.md#con
96
90
 
97
91
  ### Task classifier
98
92
 
99
- The task classifier runs automatically the Rust daemon calls `POST /classify_sessions` on the MLX server on each intelligence tick. To inspect or re-run classification:
93
+ The task classifier runs automatically inside the Rust daemon on each intelligence tick. To inspect or re-run classification:
100
94
 
101
95
  ```bash
102
96
  # Re-run classification with full logging — does NOT write to DB
@@ -11,7 +11,7 @@ For a higher-level overview (installation, daemon ops, configuration), see [`ser
11
11
  The Rust daemon owns the pipeline (ETL, coding-agent ingest, classification
12
12
  trigger, pm-worklog driver) and the database. This service is the **model
13
13
  layer**: one persistent MLX server (`server.py`) that the daemon calls over HTTP
14
- for the three jobs that need a local LLM.
14
+ for the two jobs that need a local LLM.
15
15
 
16
16
  ```
17
17
  screenpipe.db (read-only)
@@ -29,16 +29,10 @@ meridian.db → app_sessions
29
29
  │ MLX server's /summarise is the fallback. Rows walk task_method:
30
30
  │ coding_agent_live → pending_summariser → pending_classifier.
31
31
 
32
- ├── classification (src/intelligence/task_linker/, Rust → MLX)
32
+ ├── classification (src/intelligence/task_linker/, Rust)
33
33
  │ Rust reads unclassified rows and the pending_classifier queue,
34
- then sends:
35
- POST http://127.0.0.1:7823/classify_sessions
36
- │ {session_ids: [...], meridian_db: ...}
37
- │ MLX server (Qwen3.5-9B-OptiQ-4bit, FSM-constrained outlines):
38
- │ fetch session + pm_tasks + recent context
39
- │ → SessionClassification {task_key, session_type, confidence,
40
- │ reasoning, method, dimensions}
41
- │ Rust writes ticket_links + session_dimensions, advances cursor.
34
+ classifies them internally, and writes ticket_links +
35
+ session_dimensions, then advances the cursor.
42
36
 
43
37
  └── pm-worklog (Stage 4) (src/pm_worklog/, Rust → MLX)
44
38
  The Rust hour-driven driver collects each (task, hour) bundle and
@@ -260,7 +254,7 @@ When the screen is locked the selector uses `min(0.8, budget_pct × 1.5)` as the
260
254
  2. **Largest cached model fits** — if the preferred is too large, return the largest catalog model whose files are already in the HF cache and whose `min_ram_gb ≤ budget`. Avoids surprising multi-GB downloads on constrained machines.
261
255
  3. **Largest catalog model that fits (may download)** — if nothing cached fits, return the largest catalog entry where `min_ram_gb ≤ budget`, regardless of cache. This triggers a one-time download of the best available model rather than loading an oversized one that exceeds available memory. Falls back to `preferred_hf_id` only when **no catalog model fits** at all (budget so low even the 1.8 GB model won't load).
262
256
 
263
- **Why stage 3 matters on low-RAM machines:** an M1 Air (8 GB) has Metal headroom ≈ 5.4 GB. At `LLM_BUDGET_PCT=0.5` the budget is ~2.7 GB. The default preferred model is 6.5 GB (`Qwen3.5-9B-OptiQ-4bit`). Without the fix, stage 3 returned the preferred unconditionally — the server then attempted to load a 6.5 GB model into a 2.7 GB budget, causing memory pressure or an outright load failure. With the fix, stage 3 selects `Qwen3.5-4B-MLX-4bit` (2.5 GB) or `Llama-3.2-3B-Instruct-4bit` (1.8 GB) — whichever is largest and fits — and downloads it on first use.
257
+ **Why stage 3 matters on low-RAM machines:** an M1 Air (8 GB) has Metal headroom ≈ 5.4 GB. At `LLM_BUDGET_PCT=0.5` the budget is ~2.7 GB. The default preferred model is 6.5 GB (`Qwen3.5-2B-OptiQ-4bit`). Without the fix, stage 3 returned the preferred unconditionally — the server then attempted to load a 6.5 GB model into a 2.7 GB budget, causing memory pressure or an outright load failure. With the fix, stage 3 selects `Qwen3.5-4B-MLX-4bit` (2.5 GB) or `Llama-3.2-3B-Instruct-4bit` (1.8 GB) — whichever is largest and fits — and downloads it on first use.
264
258
 
265
259
  **Check what would be selected:**
266
260
 
@@ -270,7 +264,7 @@ cd services
270
264
  from agents.llm_selector import select_mlx_model_id, probe_compute
271
265
  snap = probe_compute()
272
266
  print(f'Headroom: {snap.metal_headroom_gb:.1f} GB thermal: {snap.thermal_level}')
273
- model = select_mlx_model_id('mlx-community/Qwen3.5-9B-OptiQ-4bit', 6.5, 0.5)
267
+ model = select_mlx_model_id('mlx-community/Qwen3.5-2B-OptiQ-4bit', 6.5, 0.5)
274
268
  print(f'Would load: {model}')
275
269
  "
276
270
  ```
@@ -350,14 +344,14 @@ pip install -e ".[local-llm]"
350
344
  FROM ticket_links WHERE session_id = <ID>;
351
345
  ```
352
346
 
353
- 2. **Re-run the classifier against a live session** call the MLX server directly with the session id:
347
+ 2. **Re-run the classifier against a live session** using the daemon CLI:
348
+
354
349
  ```bash
355
- curl -s -X POST http://127.0.0.1:7823/classify_sessions \
356
- -H "Content-Type: application/json" \
357
- -d "{\"session_ids\": [<ID>]}" | jq .
350
+ meridian coding-agent-classify
358
351
  ```
359
352
 
360
353
  Or use the standalone MLX script (reads from stdin, prints JSON to stdout):
354
+
361
355
  ```bash
362
356
  cd services
363
357
  echo '{"session_ids": [<ID>], "meridian_db": "'"$HOME"'/.meridian/meridian.db"}' \
@@ -0,0 +1,34 @@
1
+ """Process-global server state shared between the FastAPI app and route modules."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import threading
6
+ from typing import Any
7
+
8
+ # Populated in _lifespan (mlx_module, loaded_at, model_sem, tracer, self_url, …)
9
+ # and read by every route module.
10
+ app_state: dict[str, Any] = {}
11
+
12
+ # Shared prefetch progress, guarded by prefetch_lock. states: idle|downloading|done|error
13
+ prefetch_state: dict[str, Any] = {
14
+ "state": "idle",
15
+ "model_id": None,
16
+ "received": 0,
17
+ "total": 0,
18
+ "error": None,
19
+ }
20
+ prefetch_lock = threading.Lock()
21
+
22
+
23
+ def model_sem() -> "asyncio.Semaphore":
24
+ """Return the process-global single-slot model semaphore.
25
+
26
+ Created once in _lifespan and stored in app_state. Every endpoint that
27
+ runs a model inference acquires this before calling run_in_threadpool so
28
+ that concurrent requests never compete on the GPU.
29
+ """
30
+ sem = app_state.get("model_sem")
31
+ if sem is None: # fallback if called before lifespan (e.g. tests)
32
+ sem = asyncio.Semaphore(1)
33
+ app_state["model_sem"] = sem
34
+ return sem
@@ -0,0 +1,60 @@
1
+ """Minimal AgentOS viewer for browsing worklog pipeline traces.
2
+
3
+ Run this script to expose the agno_traces.db as an AgentOS runtime, then:
4
+ 1. Open https://os.agno.com, sign in (free account)
5
+ 2. Add new OS → Environment: Local → URL: http://localhost:8000
6
+ 3. Click "CONNECT" → refresh the page
7
+
8
+ Alternatively, browse directly at http://localhost:8000 without the hosted UI.
9
+
10
+ The DB path defaults to ~/.meridian/agno_traces.db (set AGNO_TRACE_DB to override).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ from pathlib import Path
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+ from agno.agent import Agent
21
+ from agno.db.sqlite import SqliteDb
22
+ from agno.os import AgentOS
23
+ from agno.workflow import Workflow
24
+
25
+ _DEFAULT_DB = Path("~/.meridian/agno_traces.db").expanduser()
26
+ _db_file = Path(os.environ.get("AGNO_TRACE_DB", "") or _DEFAULT_DB).expanduser()
27
+
28
+ db = SqliteDb(db_file=str(_db_file))
29
+
30
+ _agent = Agent(
31
+ name="Meridian Worklog Pipeline",
32
+ description="Worklog pipeline agent — match sessions to PM tasks and draft worklogs.",
33
+ db=db,
34
+ )
35
+
36
+ # Stub workflow registered so os.agno.com marks the OS as active.
37
+ # The real pipeline runs inside server.py; this is traces-only.
38
+ _workflow = Workflow(
39
+ name="worklog_hour",
40
+ description="Hour-level worklog pipeline: distil → report → rerank → match → draft/propose.",
41
+ db=db,
42
+ )
43
+
44
+ agent_os = AgentOS(
45
+ id="meridian-worklog",
46
+ name="Meridian Worklog Traces",
47
+ description="Viewer for worklog pipeline traces — worklog.hour, match, propose, draft spans.",
48
+ agents=[_agent],
49
+ workflows=[_workflow],
50
+ db=db,
51
+ tracing=True,
52
+ )
53
+
54
+ app = agent_os.get_app()
55
+
56
+ if __name__ == "__main__":
57
+ log.info("trace DB: %s", _db_file)
58
+ log.info("dashboard: http://localhost:8000")
59
+ log.info("control: https://os.agno.com → Add new OS → http://localhost:8000")
60
+ agent_os.serve(app="agno_viewer:app", reload=False, port=8000)
@@ -54,6 +54,14 @@ MERIDIAN_HOME = Path(os.environ.get("MERIDIAN_HOME", str(Path.home() / ".meridia
54
54
  MERIDIAN_DB = Path(os.environ.get("MERIDIAN_DB", str(MERIDIAN_HOME / "meridian.db")))
55
55
  LOG_DIR = MERIDIAN_HOME / "logs"
56
56
 
57
+ # ── Session distiller ─────────────────────────────────────────────────────────
58
+ DISTILLER_EXCLUDE_APPS: tuple[str, ...] = (
59
+ "Claude Code", "Codex", "GitHub Copilot", "Cursor Agent",
60
+ )
61
+ DISTILLER_MIN_SESSION_DUR: int = int(os.environ.get("DISTILLER_MIN_SESSION_DUR", "15"))
62
+ DISTILLER_SEM_DEDUP_THR: float = float(os.environ.get("DISTILLER_SEM_DEDUP_THR", "0.86"))
63
+ DISTILLER_DF_FRAC: float = float(os.environ.get("DISTILLER_DF_FRAC", "0.25"))
64
+
57
65
  # ── Loop tunables ─────────────────────────────────────────────────────────────
58
66
  CONFIDENCE_THRESHOLD = float(os.environ.get("CONFIDENCE_THRESHOLD", "0.65"))
59
67
  MIN_LLM_DURATION_S = int(os.environ.get("MIN_LLM_DURATION_S", "30"))
@@ -0,0 +1,195 @@
1
+ """MLX model lifecycle — load, session context manager, idle eviction.
2
+
3
+ Manages a single shared MLX generative model (Qwen3) for the agent server.
4
+ Exposes model_session() for inference, evict_resident_model() for the
5
+ single-slot guarantee (never two models resident simultaneously), and
6
+ _get_tokenizer() for callers that need the HF tokenizer directly.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import gc
11
+ import logging
12
+ import os
13
+ import threading
14
+ import time
15
+ from contextlib import contextmanager
16
+ from typing import Any, Iterator
17
+
18
+ from agents import observability
19
+
20
+ log = logging.getLogger("agents.mlx_classifier")
21
+ tracer = observability.setup("meridian-mlx-classifier")
22
+
23
+ # Default model — the agent server runs a single hardcoded model.
24
+ MODEL_ID = "mlx-community/Qwen3.5-2B-OptiQ-4bit"
25
+
26
+ _IDLE_EVICT_S = float(os.environ.get("MLX_IDLE_EVICT_S", "120"))
27
+
28
+
29
+ class _ModelBundle:
30
+ """Thin wrapper so callers can access model.model and model.mlx_tokenizer."""
31
+ __slots__ = ("model", "mlx_tokenizer")
32
+
33
+ def __init__(self, model: Any, tokenizer: Any) -> None:
34
+ self.model = model
35
+ self.mlx_tokenizer = tokenizer
36
+
37
+
38
+ _model_cache: dict[str, _ModelBundle] = {}
39
+ _tokenizer_cache: dict[str, Any] = {}
40
+ _model_lock = threading.Lock()
41
+ _in_flight = 0
42
+ _last_used = time.monotonic()
43
+
44
+
45
+ def _get_model() -> _ModelBundle:
46
+ """Return the loaded model bundle, loading from disk on the first call.
47
+
48
+ Cache-miss load is done under _model_lock (double-checked) so concurrent
49
+ callers can't double-load and the idle evictor can't race the load.
50
+ """
51
+ model_id = MODEL_ID
52
+ cached = _model_cache.get(model_id)
53
+ if cached is not None:
54
+ return cached
55
+
56
+ with _model_lock:
57
+ cached = _model_cache.get(model_id)
58
+ if cached is not None:
59
+ return cached
60
+ try:
61
+ import mlx_lm
62
+ except ImportError as exc:
63
+ raise ImportError(
64
+ f"Required package not installed: {exc}. "
65
+ "Install with: pip install 'mlx-lm>=0.22'"
66
+ ) from exc
67
+
68
+ log.info("run_task_linker_mlx: loading %s (first call this process)", model_id)
69
+ t0 = time.time()
70
+ mlx_model, tokenizer = mlx_lm.load(
71
+ model_id,
72
+ tokenizer_config={"trust_remote_code": True},
73
+ )
74
+ bundle = _ModelBundle(mlx_model, tokenizer)
75
+ log.info("run_task_linker_mlx: model loaded in %.1fs", time.time() - t0)
76
+
77
+ _model_cache[model_id] = bundle
78
+ _tokenizer_cache[model_id] = tokenizer
79
+ return bundle
80
+
81
+
82
+ def _get_tokenizer() -> Any:
83
+ """Return the HF tokenizer for the current model, loading the model if needed."""
84
+ model_id = MODEL_ID
85
+ tok = _tokenizer_cache.get(model_id)
86
+ if tok is not None:
87
+ return tok
88
+ return _get_model().mlx_tokenizer
89
+
90
+
91
+ @contextmanager
92
+ def model_session() -> Iterator[_ModelBundle]:
93
+ """Yield the loaded model bundle, marking it in-flight so the idle evictor
94
+ never frees it mid-inference. Wrap every direct model call in this.
95
+ """
96
+ global _in_flight, _last_used
97
+ with _model_lock:
98
+ _in_flight += 1
99
+ try:
100
+ yield _get_model()
101
+ finally:
102
+ with _model_lock:
103
+ _in_flight -= 1
104
+ _last_used = time.monotonic()
105
+
106
+
107
+ def maybe_evict_idle(idle_s: float | None = None) -> float | None:
108
+ """Evict the model if it's resident, nothing is in flight, and it's been
109
+ idle longer than ``idle_s`` (default MLX_IDLE_EVICT_S). Returns the GB freed,
110
+ or None if no eviction happened. Safe to call from a threadpool worker.
111
+
112
+ Uses a non-blocking lock acquire: if an inference/load is mutating state we
113
+ simply skip this tick and try again on the next one.
114
+ """
115
+ ttl = _IDLE_EVICT_S if idle_s is None else idle_s
116
+ if ttl <= 0:
117
+ return None
118
+ if not _model_lock.acquire(blocking=False):
119
+ return None
120
+ try:
121
+ if _in_flight > 0 or not _model_cache:
122
+ return None
123
+ if (time.monotonic() - _last_used) < ttl:
124
+ return None
125
+ try:
126
+ import mlx.core as mx
127
+ before = mx.get_active_memory()
128
+ except Exception: # noqa: BLE001
129
+ mx, before = None, 0
130
+ _model_cache.clear()
131
+ _tokenizer_cache.clear()
132
+ gc.collect()
133
+ freed = 0.0
134
+ if mx is not None:
135
+ mx.clear_cache()
136
+ freed = max(0.0, (before - mx.get_active_memory()) / 1e9)
137
+ log.info(
138
+ "run_task_linker_mlx: evicted idle model (idle ≥ %.0fs), freed ~%.1f GB",
139
+ ttl, freed,
140
+ )
141
+ return freed
142
+ finally:
143
+ _model_lock.release()
144
+
145
+
146
+ def evict_resident_model() -> float | None:
147
+ """Force-evict the resident generative model NOW, ignoring the idle timer.
148
+
149
+ The single-slot guarantee: the reranker must never be resident alongside
150
+ the generative model. Callers that are about to load a different model
151
+ call this first. Respects _in_flight — returns None if an inference is
152
+ running (the worklog pipeline is serialised, so nothing is in flight at a
153
+ phase boundary). Returns GB freed, or None if nothing was evicted.
154
+ """
155
+ if not _model_lock.acquire(blocking=False):
156
+ return None
157
+ try:
158
+ if _in_flight > 0 or not _model_cache:
159
+ return None
160
+ try:
161
+ import mlx.core as mx
162
+ before = mx.get_active_memory()
163
+ except Exception: # noqa: BLE001
164
+ mx, before = None, 0
165
+ _model_cache.clear()
166
+ _tokenizer_cache.clear()
167
+ gc.collect()
168
+ freed = 0.0
169
+ if mx is not None:
170
+ mx.clear_cache()
171
+ freed = max(0.0, (before - mx.get_active_memory()) / 1e9)
172
+ log.info("run_task_linker_mlx: force-evicted resident model, freed ~%.1f GB", freed)
173
+ return freed
174
+ finally:
175
+ _model_lock.release()
176
+
177
+
178
+ def model_resident() -> bool:
179
+ """True if the MLX model is currently loaded in memory."""
180
+ return bool(_model_cache)
181
+
182
+
183
+ def model_active_memory_gb() -> float | None:
184
+ """Live Metal active-memory footprint in GB, or None if MLX is unavailable.
185
+
186
+ Process-wide Metal active memory (≈ the model when resident — the model
187
+ dominates, though a transient load allocation can briefly inflate it), and
188
+ the only honest measure: ps/Activity Monitor can't see Metal unified
189
+ memory (they undercount by ~6.5 GB).
190
+ """
191
+ try:
192
+ import mlx.core as mx
193
+ return round(mx.get_active_memory() / 1e9, 2)
194
+ except Exception: # noqa: BLE001
195
+ return None
@@ -257,6 +257,9 @@ def setup(agent_name: str) -> trace.Tracer:
257
257
  This compromise matches OTel's "one resource per process" model while
258
258
  keeping `setup` idempotent in shared-library imports.
259
259
  """
260
+ import re
261
+ if not re.fullmatch(r"[A-Za-z0-9_\-]+", agent_name):
262
+ raise ValueError(f"agent_name must be alphanumeric/dash/underscore only: {agent_name!r}")
260
263
  global _PROCESS_SERVICE_NAME
261
264
 
262
265
  if agent_name in _INITIALISED:
@@ -353,7 +356,12 @@ def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
353
356
  def _configure_logging(agent_name: str) -> None:
354
357
  log_dir = Path(os.environ.get("MERIDIAN_LOG_DIR") or DEFAULT_LOG_DIR)
355
358
  log_dir.mkdir(parents=True, exist_ok=True)
356
- log_path = log_dir / f"{agent_name}.jsonl"
359
+ # Sanitise agent_name to prevent path traversal — only allow chars that are
360
+ # safe as a filename component (alphanumeric, hyphen, underscore).
361
+ safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in agent_name)
362
+ if not safe_name:
363
+ safe_name = "agent"
364
+ log_path = log_dir / f"{safe_name}.jsonl"
357
365
 
358
366
  level_name = os.environ.get("LOG_LEVEL", "INFO").upper()
359
367
  level = getattr(logging, level_name, logging.INFO)
@@ -432,4 +440,42 @@ def _configure_logging(agent_name: str) -> None:
432
440
  logging.getLogger(noisy).setLevel(logging.WARNING)
433
441
 
434
442
 
435
- __all__ = ["setup", "extract_parent_context"]
443
+ def current_traceparent() -> Optional[str]:
444
+ """Return the W3C traceparent header for the currently active OTel span.
445
+
446
+ Returns ``None`` when no span is active or the span context is invalid.
447
+ Callers pass this to loopback HTTP requests so downstream stages attach
448
+ their spans to the same trace (same pattern the Rust daemon uses via
449
+ ``crate::observability::current_traceparent()``).
450
+ """
451
+ span = trace.get_current_span()
452
+ if not span.get_span_context().is_valid:
453
+ return None
454
+ carrier: dict[str, str] = {}
455
+ TraceContextTextMapPropagator().inject(carrier)
456
+ return carrier.get("traceparent")
457
+
458
+
459
+ def instrument_agno() -> None:
460
+ """Instrument the agno framework for OpenTelemetry tracing.
461
+
462
+ No-op when openinference-instrumentation-agno is not installed; the package
463
+ is optional and the server must start cleanly without it.
464
+ """
465
+ try:
466
+ from opentelemetry.instrumentation.agno import AgnoInstrumentor # type: ignore[import]
467
+ AgnoInstrumentor().instrument()
468
+ except ImportError:
469
+ logging.getLogger(__name__).debug(
470
+ "opentelemetry-instrumentation-agno not installed; agno spans will not be exported"
471
+ )
472
+
473
+
474
+ def preview(text: Optional[str], max_chars: int = 200) -> str:
475
+ """Truncate text to `max_chars` for use as a span attribute value."""
476
+ if not text:
477
+ return ""
478
+ return text[:max_chars] + ("…" if len(text) > max_chars else "")
479
+
480
+
481
+ __all__ = ["setup", "extract_parent_context", "instrument_agno", "current_traceparent", "preview"]
@@ -2,7 +2,7 @@
2
2
 
3
3
  All tunables are read from the environment (loaded by the parent
4
4
  `agents.config` module). This file exposes them as typed constants so the
5
- rest of the package can `from agents.pm_worklog_update.config import X`.
5
+ rest of the package can `from agents.pm_worklog_update_legacy.config import X`.
6
6
 
7
7
  The defaults are chosen for a single-user laptop setup with the local 9B
8
8
  MLX server on :7823 and the user's screen-capture history in
@@ -14,7 +14,7 @@ import os
14
14
 
15
15
  # Re-export the shared paths so callers don't have to know about the parent
16
16
  # config module.
17
- from agents.config import LOG_DIR, MERIDIAN_DB, MERIDIAN_HOME # noqa: F401
17
+ from agents.config import MERIDIAN_DB, MERIDIAN_HOME # noqa: F401
18
18
 
19
19
  # ── Cadence ───────────────────────────────────────────────────────────────────
20
20
  # How often the daemon should fire a new PM update cycle, in hours.
@@ -27,7 +27,7 @@ PM_WORKLOG_INTERVAL_HOURS = float(os.environ.get("PM_WORKLOG_INTERVAL_HOURS", "1
27
27
  # OpenAILike. The port is the same one the classifier uses.
28
28
  MLX_SERVER_HOST = os.environ.get("MLX_SERVER_HOST", "127.0.0.1")
29
29
  MLX_SERVER_PORT = int(os.environ.get("MLX_SERVER_PORT", "7823"))
30
- MLX_SERVER_MODEL = os.environ.get("MLX_SERVER_MODEL", "qwen3.5-9b-instruct")
30
+ MLX_SERVER_MODEL = os.environ.get("MLX_SERVER_MODEL", "mlx-community/Qwen3.5-2B-OptiQ-4bit")
31
31
 
32
32
  # Token caps. The MLX model exposes 128-262K context — a single Synthesise
33
33
  # call comfortably swallows even the heaviest hour of work.
File without changes