@meridiona/meridian-darwin-arm64 1.68.0 → 1.69.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 (44) hide show
  1. package/.env.example +7 -2
  2. package/VERSION +1 -1
  3. package/bin/meridian +0 -0
  4. package/bin/meridian-tray +0 -0
  5. package/package.json +1 -1
  6. package/services/agents/mlx_classifier.py +27 -0
  7. package/services/agents/observability.py +159 -78
  8. package/services/agents/prompts/activity_report.py +5 -2
  9. package/services/agents/routes/activity.py +26 -120
  10. package/services/agents/routes/chat.py +60 -18
  11. package/services/agents/routes/classify.py +245 -0
  12. package/services/agents/routes/distill.py +7 -4
  13. package/services/agents/routes/generate.py +314 -0
  14. package/services/agents/routes/summarise.py +18 -54
  15. package/services/agents/routes/worklog.py +62 -13
  16. package/services/agents/schemas.py +66 -0
  17. package/services/agents/server.py +14 -5
  18. package/services/agents/structured.py +188 -0
  19. package/services/agents/tests/test_classifier.py +86 -0
  20. package/services/agents/tests/test_classify_helpers.py +38 -0
  21. package/services/agents/tests/test_generate_helpers.py +36 -0
  22. package/services/agents/tests/test_schemas.py +82 -0
  23. package/services/agents/tests/test_worklog_models.py +42 -0
  24. package/services/agents/thinking.py +265 -0
  25. package/services/agents/worklog_pipeline/classifier.py +209 -0
  26. package/services/agents/worklog_pipeline/db.py +118 -18
  27. package/services/agents/worklog_pipeline/generation.py +119 -0
  28. package/services/agents/worklog_pipeline/models.py +43 -58
  29. package/services/agents/worklog_pipeline/pipeline.py +300 -304
  30. package/services/agents/worklog_pipeline/prompts/{match_tasks.py → classify_tasks.py} +19 -7
  31. package/services/agents/worklog_pipeline/prompts/propose_ticket.py +87 -15
  32. package/services/agents/worklog_pipeline/prompts/worklog.py +58 -13
  33. package/services/agents/worklog_pipeline/workflow.py +49 -99
  34. package/services/observability/dashboards/pm-worklog-hour.json +10 -8
  35. package/services/pyproject.toml +12 -2
  36. package/services/pyrightconfig.json +9 -0
  37. package/services/tests/fixtures/classify/classify_tier1_eval.json +14 -0
  38. package/services/tests/fixtures/classify/classify_tier1_today_s_confirmed_plan.json +20 -0
  39. package/services/tests/fixtures/classify/classify_tier2_wider_backlog_batch.json +38 -0
  40. package/services/tests/fixtures/classify/eval_cases.json +25 -0
  41. package/services/uv.lock +85 -3
  42. package/services/agents/worklog_pipeline/agent_io.py +0 -75
  43. package/services/agents/worklog_pipeline/match.py +0 -145
  44. package/services/agents/worklog_pipeline/worklog.py +0 -42
package/.env.example CHANGED
@@ -45,9 +45,14 @@
45
45
  # If both are present, OAuth wins. JIRA_PROJECT_KEYS applies to either.
46
46
  # ---------------------------------------------------------------------------
47
47
 
48
- # (A) OAuth needs NO config — Meridian ships a public client id. The vars below are
49
- # optional overrides (e.g. a self-hosted app or a non-default redirect port).
48
+ # (A) OAuth in a RELEASE build needs NO config — Meridian ships a public client id
49
+ # and bakes the matching client secret in at build time (CI-only; never
50
+ # committed). A LOCAL SOURCE BUILD (cargo run / npm run tauri dev) has no
51
+ # baked-in secret, so browser OAuth login always fails there with "Jira
52
+ # OAuth requires a client secret..." until you set JIRA_OAUTH_CLIENT_SECRET
53
+ # below (ask a Meridiona team member for it) — or just use (B) instead.
50
54
  # JIRA_OAUTH_CLIENT_ID=your-atlassian-app-client-id # override the baked-in client id
55
+ # JIRA_OAUTH_CLIENT_SECRET=your-atlassian-app-client-secret # REQUIRED for OAuth on a source build
51
56
  # JIRA_OAUTH_REDIRECT_PORT=9123 # must match the app's registered redirect
52
57
  # http://127.0.0.1:<port>/callback
53
58
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 1.68.0
1
+ 1.69.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.68.0",
3
+ "version": "1.69.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": {
@@ -45,6 +45,31 @@ _in_flight = 0
45
45
  _last_used = time.monotonic()
46
46
 
47
47
 
48
+ def _invalidate_dependent_caches() -> None:
49
+ """Drop caches in other modules keyed off the (about-to-be-freed) model object.
50
+
51
+ ``agents.structured`` caches an outlines MLXLM wrapper + compiled FSM
52
+ Generator(s) per ``id(bundle.model)`` — without this call those caches
53
+ only get cleared lazily, the next time a structured-generation call
54
+ happens to see a new model id. That left the "evicted" model (and its
55
+ outlines_core Index/Guide/Vocabulary, which carry Python-side reference
56
+ cycles) fully resident for the whole gap: it both defeated this module's
57
+ single-slot residency guarantee (two generative models briefly alive at
58
+ once) and, since eviction fires here often — every reranker↔classifier
59
+ swap — let that stale generation pile up across many reload cycles
60
+ before Python's own GC schedule swept it, producing multi-GB/hour
61
+ malloc-zone growth invisible to ``mx.get_active_memory()`` (that call
62
+ only accounts Metal/unified GPU memory, not this CPU-side object graph).
63
+ Lazy import: ``agents.structured`` is not a dependency this module needs
64
+ at import time, only at eviction time.
65
+ """
66
+ try:
67
+ from agents import structured
68
+ structured.invalidate()
69
+ except Exception: # noqa: BLE001 — cache invalidation is best-effort
70
+ pass
71
+
72
+
48
73
  def _get_model() -> _ModelBundle:
49
74
  """Return the loaded model bundle, loading from disk on the first call.
50
75
 
@@ -132,6 +157,7 @@ def maybe_evict_idle(idle_s: float | None = None) -> float | None:
132
157
  mx, before = None, 0
133
158
  _model_cache.clear()
134
159
  _tokenizer_cache.clear()
160
+ _invalidate_dependent_caches()
135
161
  gc.collect()
136
162
  freed = 0.0
137
163
  if mx is not None:
@@ -167,6 +193,7 @@ def evict_resident_model() -> float | None:
167
193
  mx, before = None, 0
168
194
  _model_cache.clear()
169
195
  _tokenizer_cache.clear()
196
+ _invalidate_dependent_caches()
170
197
  gc.collect()
171
198
  freed = 0.0
172
199
  if mx is not None:
@@ -3,10 +3,9 @@
3
3
  A single `setup(agent_name)` call wires up:
4
4
 
5
5
  * an OTel `TracerProvider` with `service.name=agent_name`
6
- * direct OTLP/HTTP export to OpenObserve when `otlp_enabled=true` and
7
- credentials are set in `~/.meridian/settings.json` no Rust daemon needed
8
- * spool fallback (`~/.meridian/telemetry/pending/`) when toggle is on but
9
- credentials are absent; the Rust daemon drains that directory to OO
6
+ * export ALWAYS goes through the durable disk spool
7
+ (`~/.meridian/telemetry/pending/`), never a live HTTP call from this
8
+ process see "Spool-only export" below
10
9
  * a `LoggerProvider` + matching log handler so every `logging.LogRecord`
11
10
  is correlated to the active span via trace_id/span_id
12
11
  * W3C `TraceContextTextMapPropagator` as the global propagator so each
@@ -16,10 +15,35 @@ A single `setup(agent_name)` call wires up:
16
15
  * a JSON formatter (`python-json-logger`) writing daily-rotated JSONL files
17
16
  under `~/.meridian/logs/{agent_name}.jsonl` plus stderr
18
17
 
19
- Export gate: `~/.meridian/settings.json` `otlp_enabled` toggle + OO credentials
20
- (`oo_email` / `oo_password`). When both are present the Python service ships
21
- directly to OOit does NOT depend on the Rust daemon for delivery. The spool
22
- path is a fallback for when credentials are absent (daemon delivers later).
18
+ Export gate: `~/.meridian/settings.json` `otlp_enabled` toggle. When on, every
19
+ span/log batch is written to the spool (`_write_spool`, an atomic
20
+ tmp-then-rename into `~/.meridian/telemetry/pending/`) — the exact same
21
+ `<signal>-<unix_micros>-<seq>.otlp` layout `src/telemetry_spool/writer.rs`
22
+ produces. Generation (this process) and delivery are fully decoupled:
23
+
24
+ * The Rust daemon's `telemetry_spool::shipper` background task drains
25
+ `pending/` into OpenObserve whenever it's reachable, independent of
26
+ whether this process is even still running — `ship_one` in
27
+ `telemetry_spool/mod.rs` classifies failures as Terminal (payload is bad,
28
+ quarantined) or Retryable (network/5xx/429, retried next tick), so a
29
+ down or flaky OpenObserve never loses or corrupts anything, it just
30
+ backs up on disk until OO comes back.
31
+ * `meridian telemetry export`/`import` (`telemetry_spool/cli.rs`) lets a
32
+ user hand someone else the pending spool directly (or import one) —
33
+ the "give a customer's log bundle to support, load it into our own
34
+ OpenObserve" path this architecture exists for.
35
+
36
+ This process NEVER opens a live connection to OpenObserve itself. Earlier
37
+ revisions shipped directly via `OTLPSpanExporter`/`OTLPLogExporter` when OO
38
+ credentials were configured in settings.json (skipping the Rust daemon) —
39
+ that meant a long-running process (the MLX server) held live HTTP
40
+ export/retry state against OpenObserve, and when OO went down for hours,
41
+ continuous failed-export retries correlated with the process's memory
42
+ growing from ~100MB to 20+GB. Spool-only export removes that whole failure
43
+ class: writing to disk can't hang, retry-loop, or accumulate connection
44
+ state, and the already-hardened Rust shipper (atomic writes, terminal/
45
+ retryable classification, quarantine) is the only thing that ever talks to
46
+ OpenObserve.
23
47
 
24
48
  `extract_parent_context(traceparent)` is the helper agents use to continue
25
49
  a span emitted by another process — typically the Rust ETL or another
@@ -191,8 +215,9 @@ class SpoolLogExporter:
191
215
 
192
216
  # ──────────────────────── Config ───────────────────────────────────────────────
193
217
  DEFAULT_LOG_DIR = Path.home() / ".meridian" / "logs"
194
- # Same settings.json the Rust daemon reads. When otlp_enabled=true and
195
- # oo_email/oo_password are set, Python ships directly to OO (no daemon needed).
218
+ # Same settings.json the Rust daemon reads only the otlp_enabled toggle
219
+ # matters here now; oo_email/oo_password are read by the Rust shipper, not
220
+ # this process (see the module docstring's "Spool-only export" section).
196
221
  _SETTINGS_PATH = Path(
197
222
  os.environ.get("MERIDIAN_SETTINGS_PATH")
198
223
  or (Path.home() / ".meridian" / "settings.json")
@@ -288,27 +313,11 @@ def extract_parent_context(traceparent: Optional[str]) -> Optional[Context]:
288
313
 
289
314
 
290
315
  # ──────────────────────── Tracing setup ────────────────────────────────────────
291
- def _oo_otlp_headers() -> dict[str, str]:
292
- """Build the Basic-auth header for direct OO OTLP export from settings.json."""
293
- import base64
294
- s = _load_settings()
295
- email = s.get("oo_email") or ""
296
- passwd = s.get("oo_password") or ""
297
- if not email or not passwd:
298
- return {}
299
- token = base64.b64encode(f"{email}:{passwd}".encode()).decode()
300
- return {"Authorization": f"Basic {token}"}
301
-
302
-
303
- def _oo_otlp_base_url() -> str:
304
- """Return the OO OTLP base URL (without /v1/traces or /v1/logs suffix)."""
305
- s = _load_settings()
306
- endpoint = s.get("otlp_endpoint") or "http://localhost:5080/api/default/v1/traces"
307
- # Strip the /v1/traces suffix to get the org-level base used by both signals.
308
- for suffix in ("/v1/traces", "/v1/logs"):
309
- if endpoint.endswith(suffix):
310
- return endpoint[: -len(suffix)]
311
- return endpoint.rstrip("/")
316
+ # Spool-only by design — see the module docstring's "Spool-only export"
317
+ # section for why this process never opens a live connection to OpenObserve.
318
+ # `src/telemetry_spool/shipper.rs` (Rust daemon, runs independently) is the
319
+ # only thing that ever ships these bytes to OO; `meridian telemetry export/
320
+ # import` is the manual escape hatch when the daemon isn't running at all.
312
321
 
313
322
 
314
323
  def _configure_tracing(agent_name: str) -> None:
@@ -320,22 +329,11 @@ def _configure_tracing(agent_name: str) -> None:
320
329
 
321
330
  resource = Resource.create({"service.name": agent_name})
322
331
  provider = TracerProvider(resource=resource)
323
-
324
- headers = _oo_otlp_headers()
325
- if headers:
326
- # Credentials present ship directly to OO, no Rust daemon dependency.
327
- from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
328
- from opentelemetry.sdk.trace.export import BatchSpanProcessor
329
- exporter = OTLPSpanExporter(
330
- endpoint=f"{_oo_otlp_base_url()}/v1/traces",
331
- headers=headers,
332
- )
333
- provider.add_span_processor(BatchSpanProcessor(exporter))
334
- else:
335
- # Credentials absent — spool for the Rust daemon to deliver later.
336
- # BatchSpanProcessor avoids blocking inference threads on each span end.
337
- provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
338
-
332
+ # BatchSpanProcessor avoids blocking inference threads on each span end;
333
+ # SpoolSpanExporter itself is a synchronous local file write, so this can
334
+ # never hang, retry-loop, or accumulate connection state regardless of
335
+ # whether OpenObserve is reachable.
336
+ provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
339
337
  trace.set_tracer_provider(provider)
340
338
 
341
339
 
@@ -351,23 +349,11 @@ def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
351
349
  if not _is_otlp_enabled():
352
350
  return None
353
351
 
354
- headers = _oo_otlp_headers()
355
352
  resource = Resource.create({"service.name": agent_name})
356
353
  provider = LoggerProvider(resource=resource)
357
-
358
- if headers:
359
- # Credentials present — ship directly to OO, no Rust daemon dependency.
360
- from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
361
- log_exporter = OTLPLogExporter(
362
- endpoint=f"{_oo_otlp_base_url()}/v1/logs",
363
- headers=headers,
364
- )
365
- else:
366
- # Credentials absent — spool for the Rust daemon to deliver later.
367
- log_exporter = SpoolLogExporter() # type: ignore[assignment]
368
-
369
- # BatchLogRecordProcessor for both paths so log export never blocks callers.
370
- provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
354
+ # BatchLogRecordProcessor so log export never blocks callers — see
355
+ # _configure_tracing above for why SpoolLogExporter is always used.
356
+ provider.add_log_record_processor(BatchLogRecordProcessor(SpoolLogExporter()))
371
357
  set_logger_provider(provider)
372
358
  _LOGGER_PROVIDER = provider
373
359
  return LoggingHandler(level=logging.NOTSET, logger_provider=provider)
@@ -477,21 +463,6 @@ def current_traceparent() -> Optional[str]:
477
463
  return carrier.get("traceparent")
478
464
 
479
465
 
480
- def instrument_agno() -> None:
481
- """Instrument the agno framework for OpenTelemetry tracing.
482
-
483
- No-op when openinference-instrumentation-agno is not installed; the package
484
- is optional and the server must start cleanly without it.
485
- """
486
- try:
487
- from opentelemetry.instrumentation.agno import AgnoInstrumentor # type: ignore[import]
488
- AgnoInstrumentor().instrument()
489
- except ImportError:
490
- logging.getLogger(__name__).debug(
491
- "opentelemetry-instrumentation-agno not installed; agno spans will not be exported"
492
- )
493
-
494
-
495
466
  # Process-level handle so the agno TracerProvider isn't garbage-collected.
496
467
  _AGNO_TRACER_PROVIDER = None
497
468
 
@@ -601,11 +572,121 @@ def preview(text: Optional[str], max_chars: int = 200) -> str:
601
572
  return text[:max_chars] + ("…" if len(text) > max_chars else "")
602
573
 
603
574
 
575
+ def record_gen_params(
576
+ span,
577
+ *,
578
+ temp: float,
579
+ max_tokens: int,
580
+ thinking_budget: int,
581
+ budget_forced: bool,
582
+ enable_thinking: bool = True,
583
+ model: str = "",
584
+ ) -> None:
585
+ """Stamp the generation parameters used for ONE LLM call onto its span.
586
+
587
+ Records the per-call sampling settings — the variable ``temp`` plus the
588
+ shared constants from :mod:`agents.thinking` (top_p / top_k / presence /
589
+ repetition penalty) — together with the thinking-budget config and whether
590
+ the hard cap actually fired (``budget_forced``). Call inside the call's main
591
+ span so every LLM span in the worklog trace shows exactly which parameters
592
+ produced its output. ``model`` is set only when non-empty (some callers set
593
+ it earlier on the span themselves).
594
+ """
595
+ from agents.thinking import (
596
+ DEFAULT_TOP_P, DEFAULT_TOP_K, DEFAULT_PRESENCE_PENALTY,
597
+ DEFAULT_REPETITION_PENALTY,
598
+ )
599
+ if model:
600
+ span.set_attribute("model", model)
601
+ span.set_attribute("temp", temp)
602
+ span.set_attribute("top_p", DEFAULT_TOP_P)
603
+ span.set_attribute("top_k", DEFAULT_TOP_K)
604
+ span.set_attribute("presence_penalty", DEFAULT_PRESENCE_PENALTY)
605
+ span.set_attribute("repetition_penalty", DEFAULT_REPETITION_PENALTY)
606
+ span.set_attribute("thinking_budget", thinking_budget)
607
+ span.set_attribute("enable_thinking", enable_thinking)
608
+ span.set_attribute("max_tokens", max_tokens)
609
+ span.set_attribute("budget_forced", budget_forced)
610
+
611
+
612
+ def record_fsm_params(
613
+ span,
614
+ *,
615
+ temp: float,
616
+ max_tokens: int,
617
+ schema: str,
618
+ model: str = "",
619
+ ) -> None:
620
+ """Stamp the ACTUAL decoding config for one FSM (grammar-constrained) JSON call.
621
+
622
+ The FSM path (:mod:`agents.structured`) decodes against an outlines logits
623
+ processor compiled from ``schema`` and samples among the grammar-legal tokens with
624
+ ``temp`` / ``top_p`` / ``top_k`` (via ``make_sampler``). It runs with thinking OFF
625
+ and — unlike the thinking path — applies NO presence/repetition penalty (outlines
626
+ owns ``logits_processors``). This recorder reflects only what is genuinely applied,
627
+ so the trace never advertises phantom penalties or a thinking budget that don't
628
+ exist on these calls. Use it (not :func:`record_gen_params`) for the FSM endpoints.
629
+ """
630
+ from agents.thinking import DEFAULT_TOP_P, DEFAULT_TOP_K
631
+
632
+ if model:
633
+ span.set_attribute("model", model)
634
+ span.set_attribute("decoding", "fsm") # grammar-constrained (outlines)
635
+ span.set_attribute("grammar_constrained", True)
636
+ span.set_attribute("fsm_schema", schema) # the Pydantic output schema enforced
637
+ span.set_attribute("enable_thinking", False) # thinking is off for FSM JSON calls
638
+ span.set_attribute("temp", temp)
639
+ span.set_attribute("top_p", DEFAULT_TOP_P)
640
+ span.set_attribute("top_k", DEFAULT_TOP_K)
641
+ span.set_attribute("max_tokens", max_tokens)
642
+
643
+
644
+ def record_llm_io(
645
+ tracer,
646
+ prefix: str,
647
+ *,
648
+ system_prompt: str,
649
+ llm_input: str,
650
+ llm_output: str,
651
+ input_tokens: Optional[int] = None,
652
+ output_tokens: Optional[int] = None,
653
+ think_tokens: Optional[int] = None,
654
+ max_input_chars: int = 8000,
655
+ max_output_chars: int = 8000,
656
+ ) -> None:
657
+ """Emit the three `<prefix>.prompt` / `.input` / `.output` child spans that
658
+ OpenObserve renders as dedicated Prompt / Input / Output panels.
659
+
660
+ This is the same shape the ``activity_report`` endpoint uses, so every LLM
661
+ call in the worklog trace (classify / propose / generate) is debuggable the
662
+ same way: the exact system prompt, the exact user content, and the raw model
663
+ output — plus token counts on the output span. Call this INSIDE the call's
664
+ main span so the three land as its children.
665
+ """
666
+ with tracer.start_as_current_span(f"{prefix}.prompt") as sp:
667
+ sp.set_attribute("total_chars", len(system_prompt or ""))
668
+ sp.set_attribute("llm_input", preview(system_prompt, max_chars=max_input_chars))
669
+ with tracer.start_as_current_span(f"{prefix}.input") as sp:
670
+ sp.set_attribute("total_chars", len(llm_input or ""))
671
+ sp.set_attribute("llm_input", preview(llm_input, max_chars=max_input_chars))
672
+ with tracer.start_as_current_span(f"{prefix}.output") as sp:
673
+ sp.set_attribute("total_chars", len(llm_output or ""))
674
+ sp.set_attribute("llm_output", preview(llm_output, max_chars=max_output_chars))
675
+ if input_tokens is not None:
676
+ sp.set_attribute("input_tokens", input_tokens)
677
+ if output_tokens is not None:
678
+ sp.set_attribute("output_tokens", output_tokens)
679
+ if think_tokens is not None:
680
+ sp.set_attribute("think_tokens", think_tokens)
681
+
682
+
604
683
  __all__ = [
605
684
  "setup",
606
685
  "extract_parent_context",
607
- "instrument_agno",
608
686
  "setup_agno_tracing",
609
687
  "current_traceparent",
610
688
  "preview",
689
+ "record_gen_params",
690
+ "record_fsm_params",
691
+ "record_llm_io",
611
692
  ]
@@ -1,7 +1,8 @@
1
1
  """System prompt for the activity reporter.
2
2
 
3
- Produces a structured activity report from distilled screen-capture data.
4
- Format: TLDR + Core Tasks + Decisions + Resources.
3
+ Produces ONE consolidated activity report from the hour's distilled screen-capture
4
+ data AND any coding-agent session summaries (woven into the same story, not a
5
+ separate section). Format: TLDR + Core Tasks + Decisions + Resources.
5
6
  """
6
7
  from __future__ import annotations
7
8
 
@@ -39,6 +40,8 @@ List documentation pages, repos, articles, videos, dashboards, or other material
39
40
  ---
40
41
 
41
42
  RULES
43
+ - CONSOLIDATE everything into ONE story. Coding-agent sessions are NOT a separate task or section — weave them into the same work threads as the screen activity. If a coding-agent session and the screen capture describe the same work (same files, same feature, same ticket), MERGE them into a single thread; do not double-count or list "coding agent work" on its own.
44
+ - PRESERVE concrete identifiers verbatim — ticket keys (e.g. KAN-241), file paths, function names, PR numbers, branch-independent specifics. A downstream matcher needs these to bind the work to a ticket; never genericize or drop them.
42
45
  - Infer the PURPOSE, not just the activity. If the screen shows edits to a prompt file + model test runs, say what the developer was trying to improve and why — not just "edited prompt file and ran tests."
43
46
  - Extract identifiable specifics: system names, service names, model names, tool names — anything that helps a matcher connect this to a ticket.
44
47
  - Do not make up facts, numbers, or names not present in the input.
@@ -11,11 +11,12 @@ import logging
11
11
 
12
12
  from fastapi import APIRouter, HTTPException
13
13
  from opentelemetry import trace
14
- from opentelemetry.trace import Link
14
+ from opentelemetry.context import context as _otel_context
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from agents import observability
18
18
  from agents._state import app_state, model_sem
19
+ from agents.thinking import generate_thinking, DEFAULT_PROSE_TEMP, DEFAULT_THINKING_BUDGET
19
20
 
20
21
  log = logging.getLogger("agents.server")
21
22
 
@@ -25,11 +26,11 @@ _ACTIVITY_REPORT_MAX_TOKENS = 16384 # budget for <think> block + answer
25
26
 
26
27
 
27
28
  class _ActivityReportRequest(BaseModel):
28
- body: str
29
+ body: str # the full consolidated input (distilled OCR + coding sessions)
29
30
  label: str
30
- db_path: str | None = None
31
31
  max_tokens: int = _ACTIVITY_REPORT_MAX_TOKENS
32
32
  traceparent: str | None = None
33
+ enable_thinking: bool = True # set False to disable thinking mode for comparison
33
34
 
34
35
 
35
36
  class _ActivityReportResponse(BaseModel):
@@ -40,63 +41,15 @@ class _ActivityReportResponse(BaseModel):
40
41
  elapsed_s: float
41
42
 
42
43
 
43
- def _fetch_coding_summaries(db_path: str, label: str) -> str:
44
- """Return a formatted block of coding-agent session summaries for the given local hour label.
45
-
46
- Queries app_sessions for Claude Code / Codex / Copilot / Cursor rows whose
47
- started_at or ended_at overlaps the local hour, using the same UTC-range
48
- conversion as session_distiller so the label always means local time.
49
- Returns an empty string if none found or db_path is None.
50
- """
51
- import sqlite3
52
- from datetime import datetime, timedelta, timezone
53
-
54
- try:
55
- local_tz = datetime.now().astimezone().tzinfo
56
- local_start = datetime.strptime(label, "%Y-%m-%dT%H").replace(tzinfo=local_tz)
57
- utc_start = local_start.astimezone(timezone.utc)
58
- utc_end = (local_start + timedelta(hours=1)).astimezone(timezone.utc)
59
- utc_start_s = utc_start.strftime("%Y-%m-%dT%H:%M:%S")
60
- utc_end_s = utc_end.strftime("%Y-%m-%dT%H:%M:%S")
61
-
62
- conn = sqlite3.connect(db_path)
63
- rows = conn.execute("""
64
- SELECT app_name, started_at, ended_at, session_summary
65
- FROM app_sessions
66
- WHERE app_name IN ('Claude Code', 'Codex', 'GitHub Copilot', 'Cursor Agent')
67
- AND task_method = 'summarised'
68
- AND session_summary IS NOT NULL
69
- AND (
70
- (started_at >= ? AND started_at < ?)
71
- OR (ended_at >= ? AND ended_at < ?)
72
- )
73
- ORDER BY started_at
74
- """, (utc_start_s, utc_end_s, utc_start_s, utc_end_s)).fetchall()
75
- conn.close()
76
- except Exception as exc: # noqa: BLE001
77
- log.warning("activity_report: could not fetch coding summaries: %s", exc)
78
- return ""
79
-
80
- if not rows:
81
- return ""
82
-
83
- lines = [f"\n---\n\n## Coding Agent Sessions ({len(rows)} session{'s' if len(rows) != 1 else ''})\n"]
84
- for app_name, started_at, ended_at, summary in rows:
85
- lines.append(f"### [{app_name}] {started_at[:16]} – {ended_at[:16]}")
86
- lines.append(summary.strip())
87
- lines.append("")
88
- return "\n".join(lines)
89
-
90
-
91
44
  @router.post("/activity_report", response_model=_ActivityReportResponse)
92
45
  async def activity_report(req: _ActivityReportRequest) -> _ActivityReportResponse:
93
46
  """Human-readable worklog entry from distilled session body.
94
47
 
95
- Uses Qwen3.5-2B in thinking mode (enable_thinking=True, thinking_budget=4096)
96
- with repetition_penalty=1.1 and presence_penalty=1.5 to prevent output loops.
97
- The thinking budget caps the <think> block so it doesn't consume the full
98
- max_tokens window. The <think> block is stripped — only the final answer is
99
- returned. Token counts are from the model's own generation response.
48
+ Uses the shared agents.thinking.generate_thinking (unified sampling +
49
+ thinking-budget enforcement). The thinking-budget processor caps the <think>
50
+ block so it can't consume the whole max_tokens window; the block is stripped
51
+ and only the final markdown answer is returned. req.enable_thinking=False
52
+ disables reasoning entirely (for comparison). Token counts are the model's own.
100
53
  """
101
54
  from fastapi.concurrency import run_in_threadpool
102
55
  import time as _time
@@ -111,75 +64,18 @@ async def activity_report(req: _ActivityReportRequest) -> _ActivityReportRespons
111
64
 
112
65
  from agents.prompts.activity_report import SYSTEM as _AR_SYSTEM
113
66
 
67
+ # The caller (worklog_pipeline.stage_report) sends the FULL consolidated input —
68
+ # distilled OCR + labeled coding-agent sessions — so this endpoint just runs it.
114
69
  body = req.body
115
- if req.db_path:
116
- coding_block = _fetch_coding_summaries(req.db_path, req.label)
117
- if coding_block:
118
- body = body + coding_block
119
- log.info("activity_report: appended %d coding-summary chars", len(coding_block))
120
-
121
70
  messages = [
122
71
  {"role": "system", "content": _AR_SYSTEM},
123
72
  {"role": "user", "content": body},
124
73
  ]
125
74
 
126
- def _generate() -> tuple[str, int, int, int]:
127
- """Returns (report_markdown, input_tokens, output_tokens, think_tokens).
128
-
129
- Uses the server's already-loaded model (model.model is the raw MLX net;
130
- m._get_tokenizer() is the HF tokenizer with Qwen3.5 chat template).
131
- enable_thinking=True requires the HF tokenizer's apply_chat_template, not
132
- outlines' Chat() shim, so we call mlx_lm.generate directly.
133
- """
134
- from mlx_lm import generate
135
- from mlx_lm.sample_utils import make_sampler, make_logits_processors
136
-
137
- sampler = make_sampler(temp=1.0, top_p=0.95, top_k=20)
138
- logits_processors = make_logits_processors(
139
- repetition_penalty=1.1,
140
- repetition_context_size=64,
141
- presence_penalty=1.5,
142
- )
143
- hf_tokenizer = m._get_tokenizer()
144
- prompt_ids = hf_tokenizer.apply_chat_template(
145
- messages,
146
- add_generation_prompt=True,
147
- enable_thinking=True,
148
- thinking_budget=8192,
149
- )
150
- # A fresh HF tokenizer returns a BatchEncoding ({input_ids, attention_mask});
151
- # the mlx_lm wrapper returns a plain token list. generate() needs the list.
152
- if hasattr(prompt_ids, "keys") and "input_ids" in prompt_ids:
153
- prompt_ids = prompt_ids["input_ids"]
154
- input_tokens = len(prompt_ids)
155
-
156
- with m.model_session() as model:
157
- raw = generate(
158
- model.model, hf_tokenizer,
159
- prompt=prompt_ids,
160
- max_tokens=req.max_tokens,
161
- sampler=sampler,
162
- logits_processors=logits_processors,
163
- verbose=False,
164
- )
165
-
166
- # Strip <think>…</think> block; count its tokens
167
- think_tokens = 0
168
- if "</think>" in raw:
169
- think_part, answer = raw.split("</think>", 1)
170
- think_tokens = len(hf_tokenizer.encode(think_part + "</think>"))
171
- raw = answer.strip()
172
-
173
- output_tokens = len(hf_tokenizer.encode(raw))
174
- return raw, input_tokens, output_tokens, think_tokens
175
-
176
- _links = []
177
- if _parent_ctx is not None:
178
- _parent_span_ctx = trace.get_current_span(_parent_ctx).get_span_context()
179
- if _parent_span_ctx and _parent_span_ctx.is_valid:
180
- _links = [Link(_parent_span_ctx)]
181
-
182
- with tracer.start_as_current_span("activity_report", links=_links) as span:
75
+ with tracer.start_as_current_span(
76
+ "activity_report",
77
+ context=_parent_ctx if _parent_ctx is not None else _otel_context.Context(),
78
+ ) as span:
183
79
  span.set_attribute("gen_ai.operation.name", "chat")
184
80
  span.set_attribute("gen_ai.system", "mlx")
185
81
  span.set_attribute("distil_label", req.label)
@@ -204,7 +100,13 @@ async def activity_report(req: _ActivityReportRequest) -> _ActivityReportRespons
204
100
 
205
101
  try:
206
102
  async with model_sem():
207
- report, input_tokens, output_tokens, think_tokens = await run_in_threadpool(_generate)
103
+ _res = await run_in_threadpool(
104
+ generate_thinking, m, messages,
105
+ max_tokens=req.max_tokens, enable_thinking=req.enable_thinking,
106
+ json_mode=False, temp=DEFAULT_PROSE_TEMP)
107
+ report = _res.text
108
+ input_tokens, output_tokens, think_tokens = (
109
+ _res.input_tokens, _res.output_tokens, _res.think_tokens)
208
110
  except Exception as exc: # noqa: BLE001
209
111
  span.set_status(trace.StatusCode.ERROR, str(exc))
210
112
  log.error(
@@ -220,6 +122,10 @@ async def activity_report(req: _ActivityReportRequest) -> _ActivityReportRespons
220
122
  span.set_attribute("think_tokens", think_tokens)
221
123
  span.set_attribute("output_chars", len(report))
222
124
  span.set_attribute("elapsed_s", elapsed)
125
+ observability.record_gen_params(
126
+ span, temp=DEFAULT_PROSE_TEMP, max_tokens=req.max_tokens,
127
+ thinking_budget=DEFAULT_THINKING_BUDGET, budget_forced=_res.budget_forced,
128
+ enable_thinking=req.enable_thinking)
223
129
  span.set_attribute("is_error", False)
224
130
 
225
131
  # Span 3: model output — llm_output → OO renders as dedicated "Output" panel