@meridiona/meridian-darwin-arm64 1.64.0 → 1.66.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 +1 -1
- package/bin/meridian +0 -0
- package/bin/meridian-tray +0 -0
- package/package.json +1 -1
- package/scripts/meridian-cli.sh +19 -18
- package/services/README.md +6 -12
- package/services/agents/README.md +10 -16
- package/services/agents/_state.py +43 -0
- package/services/agents/agno_viewer.py +60 -0
- package/services/agents/config.py +8 -0
- package/services/agents/mlx_classifier.py +198 -0
- package/services/agents/model_registry.py +108 -0
- package/services/agents/observability.py +48 -2
- package/services/agents/pm_worklog_update/config.py +5 -3
- package/services/agents/prompts/__init__.py +0 -0
- package/services/agents/prompts/activity_report.py +71 -0
- package/services/agents/prompts/coding_agent_session_summary.py +28 -0
- package/services/agents/reranker.py +119 -0
- package/services/agents/routes/__init__.py +6 -0
- package/services/agents/routes/activity.py +162 -0
- package/services/agents/routes/chat.py +175 -0
- package/services/agents/routes/distill.py +103 -0
- package/services/agents/routes/health.py +41 -0
- package/services/agents/routes/prefetch.py +263 -0
- package/services/agents/routes/rerank.py +79 -0
- package/services/agents/routes/summarise.py +131 -0
- package/services/agents/routes/worklog.py +103 -0
- package/services/agents/server.py +49 -1050
- package/services/agents/session_distiller.py +560 -0
- package/services/agents/worklog_pipeline/__init__.py +11 -0
- package/services/agents/worklog_pipeline/agent_io.py +75 -0
- package/services/agents/worklog_pipeline/db.py +277 -0
- package/services/agents/worklog_pipeline/match.py +145 -0
- package/services/agents/worklog_pipeline/models.py +98 -0
- package/services/agents/worklog_pipeline/pipeline.py +418 -0
- package/services/agents/worklog_pipeline/prompts/__init__.py +1 -0
- package/services/agents/worklog_pipeline/prompts/match_tasks.py +38 -0
- package/services/agents/worklog_pipeline/prompts/propose_ticket.py +19 -0
- package/services/agents/worklog_pipeline/prompts/worklog.py +20 -0
- package/services/agents/worklog_pipeline/workflow.py +217 -0
- package/services/agents/worklog_pipeline/worklog.py +41 -0
- package/services/pyproject.toml +11 -1
- package/services/scripts/com.meridiona.mlx-server.plist +16 -3
- package/services/scripts/count_classifier_tokens.py +1 -1
- package/services/scripts/install-mlx-server-daemon.sh +3 -3
- package/services/tests/evals/README.md +7 -7
- package/services/tests/evals/configs/baseline_a_meridian.json +3 -3
- package/services/tests/evals/configs/baseline_b_generic.json +3 -3
- package/services/tests/evals/configs/extract_then_classify_b_generic.json +3 -3
- package/services/uv.lock +334 -32
- package/services/agents/_parser.py +0 -187
- package/services/agents/_prompts.py +0 -238
- package/services/agents/_system_context.py +0 -42
- package/services/agents/llm_selector.py +0 -728
- package/services/agents/run_task_linker_mlx.py +0 -1873
- package/services/agents/tests/test_llm_selector.py +0 -183
- package/services/agents/tests/test_parser.py +0 -134
- package/services/agents/tests/test_run_task_linker_mlx.py +0 -1127
- package/services/observability/dashboards/classifier-debug.json +0 -187
- package/services/observability/dashboards/pm-worklog-debug.json +0 -140
- package/services/skills/activity/task-classifier/FEEDBACK.json +0 -2656
- package/services/skills/activity/task-classifier/SKILL.applefm.md +0 -77
- package/services/skills/activity/task-classifier/SKILL.md +0 -136
- package/services/tests/conftest.py +0 -7
- package/services/tests/evals/build_dataset.py +0 -178
- package/services/tests/evals/build_real_goldens.py +0 -180
- package/services/tests/evals/classify_session.py +0 -126
- package/services/tests/evals/compare_pipeline.py +0 -262
- package/services/tests/evals/conftest.py +0 -5
- package/services/tests/evals/data/labels/real_2026-05-28.json +0 -1172
- package/services/tests/evals/data/labels/real_curated.json +0 -166
- package/services/tests/evals/data/labels/real_curated_holdout.json +0 -97
- package/services/tests/evals/data/labels/real_curated_holdout2.json +0 -64
- package/services/tests/evals/data/seeds/sessions_a_meridian.json +0 -1757
- package/services/tests/evals/data/seeds/sessions_b_generic.json +0 -1319
- package/services/tests/evals/data/seeds/tickets_generic.json +0 -72
- package/services/tests/evals/data/seeds/tickets_meridian.json +0 -87
- package/services/tests/evals/eval_classifier.py +0 -660
- package/services/tests/evals/metrics.py +0 -260
- package/services/tests/evals/render_seeds.py +0 -143
- package/services/tests/evals/strategies.py +0 -655
- package/services/tests/evals/test_classifier.py +0 -343
- package/services/tests/evals/test_model_sweep.py +0 -197
- package/services/tests/test_continuity_context.py +0 -202
- package/services/tests/test_fetch_pm_tasks.py +0 -120
- package/services/tests/test_format_candidates.py +0 -45
- package/services/tests/test_llm_selector.py +0 -223
- package/services/tests/test_prompt_cache_equivalence.py +0 -97
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.66.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.
|
|
3
|
+
"version": "1.66.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": {
|
package/scripts/meridian-cli.sh
CHANGED
|
@@ -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"
|
|
14
|
+
LABEL_UI="com.meridiona.ui"
|
|
15
15
|
LABEL_MLX="com.meridiona.mlx-server"
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
readonly LABELS=("${
|
|
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|
|
|
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
|
-
|
|
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 "${
|
|
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="${
|
|
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 "
|
|
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
|
-
|
|
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-
|
|
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
|
)
|
package/services/README.md
CHANGED
|
@@ -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 `/
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
32
|
+
├── classification (src/intelligence/task_linker/, Rust)
|
|
33
33
|
│ Rust reads unclassified rows and the pending_classifier queue,
|
|
34
|
-
│
|
|
35
|
-
│
|
|
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-
|
|
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-
|
|
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**
|
|
347
|
+
2. **Re-run the classifier against a live session** using the daemon CLI:
|
|
348
|
+
|
|
354
349
|
```bash
|
|
355
|
-
|
|
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,43 @@
|
|
|
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
|
+
# `received`/`total` are AGGREGATE byte counts summed across every pipeline model
|
|
14
|
+
# (llm + reranker + embedder), so the wizard's progress bar has a single honest
|
|
15
|
+
# denominator. The wire contract the Rust tray decodes is exactly
|
|
16
|
+
# state/received/total/error (tray/src-tauri/src/mlx_server.rs::PrefetchStatus);
|
|
17
|
+
# `models` is an additive per-model breakdown the tray ignores.
|
|
18
|
+
prefetch_state: dict[str, Any] = {
|
|
19
|
+
"state": "idle",
|
|
20
|
+
"received": 0,
|
|
21
|
+
"total": 0,
|
|
22
|
+
# Live transfer rate in bytes/sec, sourced from HF's own tqdm progress
|
|
23
|
+
# (see routes/prefetch._SpeedTqdm); 0 when not actively transferring.
|
|
24
|
+
"speed": 0.0,
|
|
25
|
+
"error": None,
|
|
26
|
+
# Per-model rows: {"role", "model_id", "loader", "received", "total", "state"}.
|
|
27
|
+
"models": [],
|
|
28
|
+
}
|
|
29
|
+
prefetch_lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def model_sem() -> "asyncio.Semaphore":
|
|
33
|
+
"""Return the process-global single-slot model semaphore.
|
|
34
|
+
|
|
35
|
+
Created once in _lifespan and stored in app_state. Every endpoint that
|
|
36
|
+
runs a model inference acquires this before calling run_in_threadpool so
|
|
37
|
+
that concurrent requests never compete on the GPU.
|
|
38
|
+
"""
|
|
39
|
+
sem = app_state.get("model_sem")
|
|
40
|
+
if sem is None: # fallback if called before lifespan (e.g. tests)
|
|
41
|
+
sem = asyncio.Semaphore(1)
|
|
42
|
+
app_state["model_sem"] = sem
|
|
43
|
+
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,198 @@
|
|
|
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 model_registry, observability
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("agents.mlx_classifier")
|
|
21
|
+
tracer = observability.setup("meridian-mlx-classifier")
|
|
22
|
+
|
|
23
|
+
# Generative/classifier checkpoint — resolved from the model registry (the single
|
|
24
|
+
# source of truth for all three pipeline models), env-overridable via MERIDIAN_LLM_ID.
|
|
25
|
+
# Exposed as a module attribute for external compatibility; the live value is read
|
|
26
|
+
# from the registry per-call inside _get_model() so a runtime env change is honoured.
|
|
27
|
+
MODEL_ID = model_registry.llm_id()
|
|
28
|
+
|
|
29
|
+
_IDLE_EVICT_S = float(os.environ.get("MLX_IDLE_EVICT_S", "120"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _ModelBundle:
|
|
33
|
+
"""Thin wrapper so callers can access model.model and model.mlx_tokenizer."""
|
|
34
|
+
__slots__ = ("model", "mlx_tokenizer")
|
|
35
|
+
|
|
36
|
+
def __init__(self, model: Any, tokenizer: Any) -> None:
|
|
37
|
+
self.model = model
|
|
38
|
+
self.mlx_tokenizer = tokenizer
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_model_cache: dict[str, _ModelBundle] = {}
|
|
42
|
+
_tokenizer_cache: dict[str, Any] = {}
|
|
43
|
+
_model_lock = threading.Lock()
|
|
44
|
+
_in_flight = 0
|
|
45
|
+
_last_used = time.monotonic()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_model() -> _ModelBundle:
|
|
49
|
+
"""Return the loaded model bundle, loading from disk on the first call.
|
|
50
|
+
|
|
51
|
+
Cache-miss load is done under _model_lock (double-checked) so concurrent
|
|
52
|
+
callers can't double-load and the idle evictor can't race the load.
|
|
53
|
+
"""
|
|
54
|
+
model_id = model_registry.llm_id()
|
|
55
|
+
cached = _model_cache.get(model_id)
|
|
56
|
+
if cached is not None:
|
|
57
|
+
return cached
|
|
58
|
+
|
|
59
|
+
with _model_lock:
|
|
60
|
+
cached = _model_cache.get(model_id)
|
|
61
|
+
if cached is not None:
|
|
62
|
+
return cached
|
|
63
|
+
try:
|
|
64
|
+
import mlx_lm
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise ImportError(
|
|
67
|
+
f"Required package not installed: {exc}. "
|
|
68
|
+
"Install with: pip install 'mlx-lm>=0.22'"
|
|
69
|
+
) from exc
|
|
70
|
+
|
|
71
|
+
log.info("mlx_classifier: loading %s (first call this process)", model_id)
|
|
72
|
+
t0 = time.time()
|
|
73
|
+
mlx_model, tokenizer = mlx_lm.load(
|
|
74
|
+
model_id,
|
|
75
|
+
tokenizer_config={"trust_remote_code": True},
|
|
76
|
+
)
|
|
77
|
+
bundle = _ModelBundle(mlx_model, tokenizer)
|
|
78
|
+
log.info("mlx_classifier: model loaded in %.1fs", time.time() - t0)
|
|
79
|
+
|
|
80
|
+
_model_cache[model_id] = bundle
|
|
81
|
+
_tokenizer_cache[model_id] = tokenizer
|
|
82
|
+
return bundle
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_tokenizer() -> Any:
|
|
86
|
+
"""Return the HF tokenizer for the current model, loading the model if needed."""
|
|
87
|
+
model_id = model_registry.llm_id()
|
|
88
|
+
tok = _tokenizer_cache.get(model_id)
|
|
89
|
+
if tok is not None:
|
|
90
|
+
return tok
|
|
91
|
+
return _get_model().mlx_tokenizer
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@contextmanager
|
|
95
|
+
def model_session() -> Iterator[_ModelBundle]:
|
|
96
|
+
"""Yield the loaded model bundle, marking it in-flight so the idle evictor
|
|
97
|
+
never frees it mid-inference. Wrap every direct model call in this.
|
|
98
|
+
"""
|
|
99
|
+
global _in_flight, _last_used
|
|
100
|
+
with _model_lock:
|
|
101
|
+
_in_flight += 1
|
|
102
|
+
try:
|
|
103
|
+
yield _get_model()
|
|
104
|
+
finally:
|
|
105
|
+
with _model_lock:
|
|
106
|
+
_in_flight -= 1
|
|
107
|
+
_last_used = time.monotonic()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def maybe_evict_idle(idle_s: float | None = None) -> float | None:
|
|
111
|
+
"""Evict the model if it's resident, nothing is in flight, and it's been
|
|
112
|
+
idle longer than ``idle_s`` (default MLX_IDLE_EVICT_S). Returns the GB freed,
|
|
113
|
+
or None if no eviction happened. Safe to call from a threadpool worker.
|
|
114
|
+
|
|
115
|
+
Uses a non-blocking lock acquire: if an inference/load is mutating state we
|
|
116
|
+
simply skip this tick and try again on the next one.
|
|
117
|
+
"""
|
|
118
|
+
ttl = _IDLE_EVICT_S if idle_s is None else idle_s
|
|
119
|
+
if ttl <= 0:
|
|
120
|
+
return None
|
|
121
|
+
if not _model_lock.acquire(blocking=False):
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
if _in_flight > 0 or not _model_cache:
|
|
125
|
+
return None
|
|
126
|
+
if (time.monotonic() - _last_used) < ttl:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
import mlx.core as mx
|
|
130
|
+
before = mx.get_active_memory()
|
|
131
|
+
except Exception: # noqa: BLE001
|
|
132
|
+
mx, before = None, 0
|
|
133
|
+
_model_cache.clear()
|
|
134
|
+
_tokenizer_cache.clear()
|
|
135
|
+
gc.collect()
|
|
136
|
+
freed = 0.0
|
|
137
|
+
if mx is not None:
|
|
138
|
+
mx.clear_cache()
|
|
139
|
+
freed = max(0.0, (before - mx.get_active_memory()) / 1e9)
|
|
140
|
+
log.info(
|
|
141
|
+
"mlx_classifier: evicted idle model (idle ≥ %.0fs), freed ~%.1f GB",
|
|
142
|
+
ttl, freed,
|
|
143
|
+
)
|
|
144
|
+
return freed
|
|
145
|
+
finally:
|
|
146
|
+
_model_lock.release()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def evict_resident_model() -> float | None:
|
|
150
|
+
"""Force-evict the resident generative model NOW, ignoring the idle timer.
|
|
151
|
+
|
|
152
|
+
The single-slot guarantee: the reranker must never be resident alongside
|
|
153
|
+
the generative model. Callers that are about to load a different model
|
|
154
|
+
call this first. Respects _in_flight — returns None if an inference is
|
|
155
|
+
running (the worklog pipeline is serialised, so nothing is in flight at a
|
|
156
|
+
phase boundary). Returns GB freed, or None if nothing was evicted.
|
|
157
|
+
"""
|
|
158
|
+
if not _model_lock.acquire(blocking=False):
|
|
159
|
+
return None
|
|
160
|
+
try:
|
|
161
|
+
if _in_flight > 0 or not _model_cache:
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
import mlx.core as mx
|
|
165
|
+
before = mx.get_active_memory()
|
|
166
|
+
except Exception: # noqa: BLE001
|
|
167
|
+
mx, before = None, 0
|
|
168
|
+
_model_cache.clear()
|
|
169
|
+
_tokenizer_cache.clear()
|
|
170
|
+
gc.collect()
|
|
171
|
+
freed = 0.0
|
|
172
|
+
if mx is not None:
|
|
173
|
+
mx.clear_cache()
|
|
174
|
+
freed = max(0.0, (before - mx.get_active_memory()) / 1e9)
|
|
175
|
+
log.info("mlx_classifier: force-evicted resident model, freed ~%.1f GB", freed)
|
|
176
|
+
return freed
|
|
177
|
+
finally:
|
|
178
|
+
_model_lock.release()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def model_resident() -> bool:
|
|
182
|
+
"""True if the MLX model is currently loaded in memory."""
|
|
183
|
+
return bool(_model_cache)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def model_active_memory_gb() -> float | None:
|
|
187
|
+
"""Live Metal active-memory footprint in GB, or None if MLX is unavailable.
|
|
188
|
+
|
|
189
|
+
Process-wide Metal active memory (≈ the model when resident — the model
|
|
190
|
+
dominates, though a transient load allocation can briefly inflate it), and
|
|
191
|
+
the only honest measure: ps/Activity Monitor can't see Metal unified
|
|
192
|
+
memory (they undercount by ~6.5 GB).
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
import mlx.core as mx
|
|
196
|
+
return round(mx.get_active_memory() / 1e9, 2)
|
|
197
|
+
except Exception: # noqa: BLE001
|
|
198
|
+
return None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Single source of truth for the on-device models the pipeline needs.
|
|
2
|
+
|
|
3
|
+
The end-to-end worklog pipeline (classification → worklog update) runs three
|
|
4
|
+
distinct models, each in its own single-slot turn (the runtime never keeps two
|
|
5
|
+
resident at once — see ``mlx_classifier.evict_resident_model``):
|
|
6
|
+
|
|
7
|
+
role default checkpoint loader used by
|
|
8
|
+
──────── ─────────────────────────────────────── ────────────── ──────────────────────────────────
|
|
9
|
+
llm mlx-community/Qwen3.5-2B-OptiQ-4bit mlx_lm classify · match · /summarise · /activity_report · worklog synth · propose
|
|
10
|
+
reranker kerncore/Qwen3-Reranker-0.6B-MLX-4bit mlx_lm ticket↔worklog scoring (/rerank)
|
|
11
|
+
embedder mlx-community/Qwen3-Embedding-0.6B-8bit mlx_embeddings session-distillation SemDeDup (/distill_hour)
|
|
12
|
+
|
|
13
|
+
There is no separate "classifier" model: classification and matching run on the
|
|
14
|
+
``llm`` checkpoint via the OpenAI-compatible endpoint.
|
|
15
|
+
|
|
16
|
+
Every checkpoint is env-overridable (defaults below) so eval/experiments can
|
|
17
|
+
swap any role without code edits. This module is the ONLY place these ids and
|
|
18
|
+
their HuggingFace download filesets are declared — ``mlx_classifier``,
|
|
19
|
+
``reranker``, ``session_distiller`` and the ``/prefetch_model`` route all read
|
|
20
|
+
from here, so onboarding can eagerly fetch exactly the set the runtime will load.
|
|
21
|
+
|
|
22
|
+
# Who reads this
|
|
23
|
+
agents.mlx_classifier → MODEL_ID (llm)
|
|
24
|
+
agents.reranker → _RERANKER_ID (reranker)
|
|
25
|
+
agents.session_distiller → embedder load (embedder)
|
|
26
|
+
agents.routes.prefetch → eager multi-model download for the setup wizard
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
# mlx_lm.load()'s default fileset — exactly what a generative or reranker MLX
|
|
34
|
+
# repo resolves on load(). The mlx-community embedding repos are plain
|
|
35
|
+
# transformer encoders shipping the same config/safetensors/tokenizer layout,
|
|
36
|
+
# so this pattern set covers all three roles. Stored as a tuple (immutable) so
|
|
37
|
+
# each ModelSpec gets independent identity — list mutation would silently corrupt
|
|
38
|
+
# all three specs' snapshot_download filters simultaneously.
|
|
39
|
+
_MLX_ALLOW_PATTERNS: tuple[str, ...] = (
|
|
40
|
+
"*.json", "model*.safetensors", "*.py", "tokenizer.model",
|
|
41
|
+
"*.tiktoken", "tiktoken.model", "*.txt", "*.jsonl", "*.jinja",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ModelSpec:
|
|
47
|
+
"""One model role: its env override, default checkpoint, loader, and fileset.
|
|
48
|
+
|
|
49
|
+
``loader`` selects the runtime entry point — ``"mlx_lm"`` for generative /
|
|
50
|
+
reranker weights (``mlx_lm.load``) and ``"mlx_embeddings"`` for the encoder
|
|
51
|
+
(``mlx_embeddings.load``). ``allow_patterns`` is the HF download filter the
|
|
52
|
+
prefetch route applies so it fetches exactly what ``load()`` will resolve.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
role: str
|
|
56
|
+
env_var: str
|
|
57
|
+
default_id: str
|
|
58
|
+
loader: str
|
|
59
|
+
allow_patterns: tuple[str, ...]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def model_id(self) -> str:
|
|
63
|
+
"""Resolved checkpoint id — the env override if set, else the default."""
|
|
64
|
+
return os.environ.get(self.env_var, self.default_id)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
LLM = ModelSpec(
|
|
68
|
+
role="llm",
|
|
69
|
+
env_var="MERIDIAN_LLM_ID",
|
|
70
|
+
default_id="mlx-community/Qwen3.5-2B-OptiQ-4bit",
|
|
71
|
+
loader="mlx_lm",
|
|
72
|
+
allow_patterns=_MLX_ALLOW_PATTERNS,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
RERANKER = ModelSpec(
|
|
76
|
+
role="reranker",
|
|
77
|
+
env_var="WORKLOG_RERANKER_ID",
|
|
78
|
+
default_id="kerncore/Qwen3-Reranker-0.6B-MLX-4bit",
|
|
79
|
+
loader="mlx_lm",
|
|
80
|
+
allow_patterns=_MLX_ALLOW_PATTERNS,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
EMBEDDER = ModelSpec(
|
|
84
|
+
role="embedder",
|
|
85
|
+
env_var="MERIDIAN_EMBEDDER_ID",
|
|
86
|
+
default_id="mlx-community/Qwen3-Embedding-0.6B-8bit",
|
|
87
|
+
loader="mlx_embeddings",
|
|
88
|
+
allow_patterns=_MLX_ALLOW_PATTERNS,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Ordered llm → reranker → embedder: the wizard prefetches in this order, and the
|
|
92
|
+
# llm is largest so its progress dominates the bar first.
|
|
93
|
+
ALL_SPECS: tuple[ModelSpec, ...] = (LLM, RERANKER, EMBEDDER)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def llm_id() -> str:
|
|
97
|
+
"""Resolved generative/classifier checkpoint id."""
|
|
98
|
+
return LLM.model_id
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def reranker_id() -> str:
|
|
102
|
+
"""Resolved reranker checkpoint id."""
|
|
103
|
+
return RERANKER.model_id
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def embedder_id() -> str:
|
|
107
|
+
"""Resolved embedder checkpoint id."""
|
|
108
|
+
return EMBEDDER.model_id
|