-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.py
More file actions
1395 lines (1153 loc) · 55.8 KB
/
Copy pathserver.py
File metadata and controls
1395 lines (1153 loc) · 55.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
import functools
import json
import logging
import os
import sys
import httpx
from fastmcp import FastMCP
from tallyman_core import (
AliasExists,
AliasNotFound,
CellNotFound,
ChartSpecError,
DisplayKlassError,
PostProcessingRunError,
PostProcessingSourceError,
StatSourceError,
alias_for_hash,
entry_dir,
export_notebook_path,
get_alias,
history_for,
list_display_klasses,
list_post_processings,
list_projects,
list_stats,
notebook,
record_error,
remove_alias,
remove_display_klass,
remove_post_processing,
remove_stat,
rename_alias,
resolve_project,
run_post_processing,
set_alias,
set_chart,
write_display_klass,
write_post_processing,
write_stat,
)
from tallyman_xorq import BuildError, build_and_persist, full_diff, list_entries
log = logging.getLogger("tallyman_mcp")
COMPANION_URL = os.environ.get("TALLYMAN_COMPANION_URL", "http://127.0.0.1:7860")
mcp = FastMCP("tallyman")
# Tracks the active project name observed at the end of the previous tool
# invocation, so the next call can detect a mid-conversation switch and
# surface a ``warning`` field (design decision 14 of the project-switcher
# plan). ``None`` until the first tool call seeds it.
_last_project: str | None = None
# Session-local active project. Set in-process by project_switch/project_new
# so that switching once is sticky for all subsequent calls in this MCP
# process, regardless of what another Claude session or shell command writes to
# ~/.tallyman/active_project between calls. Falls back to the disk file if
# never explicitly set (first run, or direct-file manipulation).
_mcp_active_project: str | None = None
def _resolve_active_project() -> str:
"""Active project for this MCP process: in-process override beats disk."""
return _mcp_active_project or resolve_project()
def _tag_project(fn):
"""Wrap a tool function so every response carries the active project.
Behavior:
- If the wrapped tool returns a ``dict``, ``result.setdefault("project",
<current>)`` is applied so the LLM always knows which project the
call landed in.
- If the wrapped tool returns a list (e.g. ``catalog_list``), it is
wrapped as ``{"project": <current>, "items": [...]}``. This is a
documented breaking change to those tools' surface — the trade-off
is uniformity: every MCP response is now a dict with a ``project``
key.
- When ``_last_project`` differs from the current resolution, a
``warning`` field is added with the wording from the design plan.
- On the very first tool call, no warning is emitted; ``_last_project``
is simply seeded.
"""
@functools.wraps(fn)
def wrapped(*args, **kwargs):
global _last_project, _mcp_active_project
result = fn(*args, **kwargs)
current = _resolve_active_project()
# Seed in-process state on the very first call so future calls are
# sticky even if the disk file changes after this point.
if _mcp_active_project is None:
_mcp_active_project = current
if not isinstance(result, dict):
result = {"items": result}
result.setdefault("project", current)
if _last_project is not None and _last_project != current:
result["warning"] = (
f"active project changed from {_last_project!r} to {current!r} "
f"since the last call; proceeding with {current!r}"
)
_last_project = current
return result
return wrapped
# ---------------------------------------------------------------------------
# Dispatch-boundary checkpoint (reset-to-revision, plans/adr-reset-to-revision.md)
# ---------------------------------------------------------------------------
# Read-only and lifecycle tools that must NOT append a revision. Everything
# else checkpoints by default — opt-out, so a tool added next month is covered
# automatically and coverage cannot silently regress (pinned by
# tests/test_reset_to_revision.py).
_NO_CHECKPOINT = frozenset(
{
"catalog_list",
"catalog_diff",
"catalog_list_summary_stats",
"catalog_list_post_processings",
"catalog_run_post_processing", # preview, persists nothing
"catalog_export_marimo", # export artifact, not state
"project_list",
"project_switch",
"project_new", # lifecycle; genesis happens inside the tool itself
}
)
# Names of every tool that registered through the checkpointing decorator —
# introspection surface for the opt-out coverage test.
_CHECKPOINTED_TOOLS: set[str] = set()
def _with_checkpoint(fn):
"""Checkpoint the catalog once after a successful mutating tool call.
Applied to every tool at registration (see ``_checkpointing_tool``). The
checkpoint is taken at the dispatch boundary, after the operation returns
without error — one revision per operation even when the tool calls several
``tallyman_core`` mutators (#33's multi-commit bug). A checkpoint failure is
logged, never raised: it must not break the tool response.
"""
@functools.wraps(fn)
def wrapped(*args, **kwargs):
result = fn(*args, **kwargs)
if fn.__name__ in _NO_CHECKPOINT:
return result
if isinstance(result, dict) and result.get("error"):
return result
try:
from tallyman_core.catalog_state import checkpoint_catalog # noqa: PLC0415
project = _resolve_active_project()
if project:
checkpoint_catalog(project, f"tallyman: {fn.__name__}")
except Exception as exc:
log.warning("checkpoint after %s failed: %s", fn.__name__, exc)
return result
return wrapped
_original_tool = mcp.tool
def _checkpointing_tool(*dargs, **dkwargs):
"""``mcp.tool`` wrapped so registration applies the checkpoint hook."""
register = _original_tool(*dargs, **dkwargs)
def _register(fn):
_CHECKPOINTED_TOOLS.add(fn.__name__)
return register(_with_checkpoint(fn))
return _register
mcp.tool = _checkpointing_tool
def _notify(kind: str, content_hash: str | None = None, **extra) -> None:
"""Best-effort POST to the companion's /internal/notify. Never raise."""
try:
with httpx.Client(timeout=2.0) as client:
client.post(
f"{COMPANION_URL}/internal/notify",
json={"kind": kind, "hash": content_hash, "extra": extra or None},
)
except Exception as exc:
# Companion may not be up; that's allowed. Log and move on.
print(f"[tallyman_mcp] notify failed ({kind}): {exc}", file=sys.stderr)
@mcp.tool()
@_tag_project
def catalog_run(code: str, prompt: str = "") -> dict:
"""Execute a xorq expression script and persist it as a catalog entry.
The provided code MUST bind a top-level variable named `expr` to a xorq/ibis
expression. The four canonical names are PRE-INJECTED — no import statement
needed for basic usage:
ibis # xorq.vendor.ibis — ibis._, ibis.cases, ibis.window,
# ibis.literal, ibis.coalesce, ibis.desc, ibis.schema
xo # xorq.api — xo.memtable, xo.deferred_read_parquet, xo.connect
from_catalog # read a named catalog entry or content hash
from_project # read a raw file under <project>/data/
MINIMAL PATTERN (no imports needed):
t = from_catalog("alias_name") # named entry
t = from_project("file.parquet") # raw file under <project>/data/
expr = t.filter(t.col > 0).group_by("region").aggregate(n=t.count())
expr = t.mutate(pk=ibis._.a + "_" + ibis._.b) # ibis._ available pre-injected
Explicit `import xorq.api as xo` or `import xorq.vendor.ibis as ibis` still work
and are fine for clarity — they just overwrite the pre-injected binding with the
same module.
FORBIDDEN IMPORTS — the linter rejects these before any code runs:
import ibis # imports system ibis (no datafusion backend); use
# the pre-injected ibis or `import xorq.vendor.ibis as ibis`
from ibis import X # same problem — use xorq.vendor.ibis
import xorq as xo # bare xorq has no API methods; use `import xorq.api as xo`
import xorq # same — use `import xorq.api as xo`
Math is a COLUMN METHOD: col.sin(), col.log(), col.sqrt() — not ibis.sin(col).
Read data only via from_project / from_catalog (no xo.read_parquet / ibis.read_parquet).
ONLY BACKEND — xorq's built-in datafusion; there is NO duckdb. Do not use
`ibis.duckdb`, a duckdb connection, `.sql()`, or `con.register()`. Build
everything with the ibis expression API over from_project/from_catalog.
DATA SOURCING — choose between `from_catalog` and `from_project`:
- `from_catalog("name")` resolves catalog aliases AND bare content hashes.
Use this when the source appears in `catalog_list` output.
- `from_project("file.parquet")` reads raw files under `<project>/data/`.
Use this only for raw files visible on disk (not catalog aliases).
- When in doubt, call `catalog_list` first. A name that looks like a file
is often actually an alias — `from_project` on an alias raises
`ProjectDataNotFound`.
COLUMN NAMES — do not guess. The source step returns a `schema`, and
`catalog_list` shows each entry's columns as a compact `name:type, ...`
summary. Reference the exact names you see there (`tripduration`, not
`trip_duration`). When unsure, index with `t["col"]` rather than `t.col`:
the bracket form's error lists the real columns, whereas attribute access
on a missing column raises an opaque AttributeError.
GOTCHAS — types and casts:
# BAD: timestamp subtraction yields IntervalColumn — fails on .mean()
# AND on parquet write. Cast or use epoch_seconds() arithmetic.
expr = t.group_by("k").aggregate(avg=(t.b - t.a).mean()) # AttributeError on .mean()
expr = t.mutate(d=t.b - t.a) # parquet write fails
# CORRECT:
expr = t.mutate(d=(t.b - t.a).cast("int64")) # microseconds
expr = t.mutate(d=t.b.epoch_seconds() - t.a.epoch_seconds()) # seconds
# BAD: ibis.memtable() with date/timestamp values — pyarrow type coercion fails.
t = ibis.memtable({"d": [ibis.literal("2024-01-01").cast("date")]}) # FAILS
# CORRECT: use string columns and cast in the expression.
t = ibis.memtable({"d": ["2024-01-01"]})
expr = t.mutate(d=t.d.cast("date"))
LOADING RAW CSV FILES — always inspect first:
Before writing a schema for deferred_read_csv, run `head -5 <file>` to
verify the actual column names and order. Do NOT guess — yfinance and
similar sources produce non-obvious layouts:
- yfinance multi-download CSVs have two extra header rows (a "Price"
label row and a ticker row) before the real headers. Detect this with
head; use skip_rows=2 only when those rows are actually present.
- yfinance column order is alphabetical: Close, High, Low, Open, Volume
(NOT the conventional Open, High, Low, Close, Volume order).
- The first column is often literally named "Price" in raw yfinance output,
not "Date".
ALWAYS use an explicit schema with deferred_read_csv — without one, type
inference silently misassigns types (e.g. date columns become timestamps).
DATE-ONLY COLUMNS — use 'date', never 'timestamp':
# BAD: 'timestamp' stores midnight UTC; Western-timezone display shifts
# the date back by one day (2026-05-19 → shows as 2026-05-18).
import xorq.vendor.ibis as ibis
schema = ibis.schema({'Date': 'timestamp', ...}) # off-by-one in viewer
# CORRECT: 'date' has no time component, no timezone conversion.
schema = ibis.schema({'Date': 'date', ...}) # import xorq.vendor.ibis as ibis
This applies to any date-only column: OHLCV Date, trading calendars,
event dates, anything that looks like YYYY-MM-DD in the CSV.
GOTCHAS — pandas reflexes that silently fail:
t.x.describe() # No describe() — decompose into explicit aggregates.
t.ts.dt.hour # No .dt accessor — call .hour() directly on the column.
t.select("*") # No wildcard expansion — list columns or skip select().
t.select(ibis._, x=…) # No ibis._ wildcard selector — prefer mutate() for additions.
# CORRECT:
t.aggregate(mean=t.x.mean(), std=t.x.std(), min=t.x.min(), max=t.x.max())
t.mutate(h=t.ts.hour())
t.select("col_a", "col_b") # explicit columns
t.mutate(new_col=t.x + 1) # not select(ibis._, ...)
GOTCHAS — group_by / aggregate patterns:
# WRONG: .agg() is a pandas shorthand — ibis/xorq uses .aggregate()
t.group_by("k").agg(n=t.x.count()) # AttributeError: 'agg' not found
# CORRECT:
t.group_by("k").aggregate(n=t.x.count())
# WRONG: .sort() is pandas/polars — ibis uses .sort_by() or .order_by()
agg.sort("n", descending=True) # AttributeError: 'sort' not found
# CORRECT:
agg.sort_by("n", descending=True)
agg.order_by(ibis.desc("n"))
# WRONG: .alias() to rename a computed column expression
distance_col.alias("distance_km") # AttributeError: 'alias' not found
# CORRECT:
distance_col.name("distance_km")
# WRONG: referencing the *source* table in post-aggregate operations
agg = t.group_by(["a", "b"]).aggregate(n=t.x.count())
agg.order_by(t.n.desc()) # t has no column 'n'; fails or silently wrong
agg.select(t.a, t.b, agg.n) # mixes references across different relations
# CORRECT: always reference the *result* table; use bracket form for safety
agg.order_by(ibis.desc("n"))
agg.select(agg["a"], agg["b"], agg["n"])
# CANONICAL multi-step pattern (mutate → group_by → aggregate → sort → select):
with_dur = t.mutate(dur_s=(t.ended_at - t.started_at).cast("int64"))
agg = (
with_dur
.group_by(["start_col", "end_col"])
.aggregate(
n=with_dur.dur_s.count(),
avg_dur=with_dur.dur_s.mean(),
lat=with_dur.start_lat.first(),
)
)
result = agg.order_by(ibis.desc("n")).limit(500)
expr = result.select(
result["start_col"], result["end_col"],
result["n"], result["avg_dur"], result["lat"],
)
GOTCHAS — referencing aggregate columns:
# BAD: when an aggregate alias collides with an ibis method name
# (count, min, max, mean, sum, std), attribute access returns the method.
agg = t.group_by("k").aggregate(count=t.x.count())
agg.filter(agg.count > 100) # TypeError: '>' not supported between method and int
agg.order_by(agg.count.desc()) # AttributeError: 'function' has no attribute 'desc'
# CORRECT: reference by string, or pick a non-shadowing alias.
agg.filter(agg["count"] > 100)
agg.order_by(ibis.desc("count"))
agg = t.group_by("k").aggregate(n=t.x.count()) # avoids shadowing entirely
COLUMN ORDER — put new columns first:
When the expression adds a computed column to an existing table,
select the new column first so it appears at the left of the viewer.
# BAD:
expr = t.mutate(tripduration=(t.ended_at - t.started_at).cast("int64"))
# CORRECT — new column leads:
expr = t.select(
tripduration=(t.ended_at - t.started_at).cast("int64"),
*t.columns,
)
# Or equivalently with mutate + relocation:
expr = t.mutate(tripduration=(t.ended_at - t.started_at).cast("int64"))
expr = expr.select("tripduration", *[c for c in t.columns])
GOTCHAS — APIs that DON'T exist (do not invent these):
ibis.to_date(...) / ibis.re_replace(...) # NOT real. Parse string dates with:
t.mutate(d=t.date_str.as_timestamp("%b %d %Y")) # strptime-style format
ibis.struct(mean=..., std=...) # struct() takes a dict, not kwargs.
# For several per-group stats use separate aggregate kwargs — NOT a struct:
t.group_by("k").aggregate(mean_x=t.x.mean(), std_x=t.x.std(), n=t.x.count())
WINDOW FUNCTIONS (rolling / lag):
w = ibis.window(group_by="symbol", order_by="d", preceding=2, following=0)
t.mutate(ma3=t.price.mean().over(w), prev=t.price.lag(1).over(w))
# kwargs are group_by/order_by/preceding/following — NOT partition_by / rows=(-2, 0).
CASE / binning — prefer ibis.cases() (ibis.case().when() is deprecated):
b = ibis.cases((t.x < 1, "lo"), (t.x < 2, "mid"), else_="hi")
MODELING — fit sklearn models as catalog entries, NEVER inside a
post-processing process() function (that sandbox blocks third-party
imports). The fit becomes part of the expression and the trained model is
serialized into the build — a content-hashed, diffable catalog entry:
import xorq.expr.datatypes as dt
from xorq.ml import deferred_fit_predict_sklearn, train_test_splits
from sklearn.linear_model import LinearRegression
t = from_catalog("diamond_features")
train, test = train_test_splits(t, test_sizes=0.25, random_seed=42)
deferred = deferred_fit_predict_sklearn(cls=LinearRegression, return_type=dt.float64)
instance = deferred(train, target="price", features=["carat", "depth", "x", "y", "z"])
expr = test.mutate(price_pred=instance.deferred_other.on_expr(test)) # rename the predict col
# SCORE in pure ibis on the prediction column. deferred_sklearn_metric does NOT
# survive build_and_persist (its MetricComputation op has no build serializer):
# acc = preds.aggregate(accuracy=(preds.species == preds.species_pred).mean())
# rmse = preds.aggregate(rmse=((preds.price - preds.price_pred) ** 2).mean().sqrt())
# deferred_fit_transform_sklearn (transforms) and Pipeline/Step also exist.
DO NOT:
- fabricate placeholder values that look like real output (e.g. a
memtable of zeros) when you cannot compute the requested result —
return an error or a clearly-labelled column instead.
- pass a project= argument to from_project/from_catalog; the active
project is implicit.
Args:
code: A self-contained Python script that binds `expr`.
prompt: Optional human-readable description (the user's intent).
Returns:
dict with keys: hash, row_count, execute_seconds, schema, entry_path.
"""
project = _resolve_active_project()
out = _run_and_record(project, code, prompt, tool="catalog_run")
if "error" in out:
return out
out.pop("_build", None)
_notify("new_entry", content_hash=out["hash"])
return out
@mcp.tool()
@_tag_project
def catalog_load_parquet(rel_path: str, prompt: str = "", name: str = "") -> dict:
"""Register a parquet file from the project's data/ directory as a catalog entry.
Use this for simple "load this file" steps. The agent does not need to write
any xorq code — pass a path relative to the project's `data/` directory.
Args:
rel_path: Path relative to `<project>/data/`. Example: "orders.parquet".
prompt: Optional human-readable description (the user's intent).
name: Optional alias to register the entry under. If provided, the
entry behaves like one created by `catalog_create` (named, appended
to the notebook). Errors if the alias already exists.
Returns:
Same shape as catalog_run, plus `alias`/`version` when `name` is set.
"""
project = _resolve_active_project()
if name and get_alias(project, name) is not None:
return {"error": f"alias {name!r} already exists. Use catalog_revise to update it."}
code = f"from tallyman_xorq.io import from_project\nexpr = from_project({rel_path!r})\n"
out = _run_and_record(project, code, prompt, tool="catalog_load_parquet")
if "error" in out:
return out
out.pop("_build", None)
if name:
info = set_alias(project, name, out["hash"], expect_exists=False)
notebook.append(project, name, markdown=prompt or "")
out["alias"] = info["name"]
out["version"] = info["version"]
_notify("new_entry", content_hash=out["hash"], alias=name, version=info["version"])
_notify("notebook_changed")
else:
_notify("new_entry", content_hash=out["hash"])
return out
def _entry_url(project: str, content_hash: str) -> str:
return f"{COMPANION_URL}/{project}/catalog/{content_hash}"
def _run_and_record(project: str, code: str, prompt: str, *, tool: str = "catalog_run") -> dict:
"""Shared body for tools that compile-and-persist; returns the tool reply dict."""
try:
result = build_and_persist(project=project, code=code, prompt=prompt or None)
except BuildError as exc:
rec = record_error(project, code=code, message=str(exc), prompt=prompt or None, tool=tool)
_notify("build_failed", error_id=rec["id"], tool=tool)
return {"error": str(exc), "error_id": rec["id"]}
return {
"_build": result,
"hash": result.content_hash,
"row_count": result.row_count,
"execute_seconds": result.execute_seconds,
"schema": result.schema,
"entry_path": str(result.entry_path),
"url": _entry_url(project, result.content_hash),
}
@mcp.tool()
@_tag_project
def catalog_create(name: str, code: str, prompt: str = "") -> dict:
"""Execute and persist a NAMED catalog entry (alias).
Use this when the user gives a concept a name ("name this `shoe_sales`").
Errors if the alias already exists — use `catalog_revise` to update an
existing alias instead.
Code conventions and gotchas are documented in `catalog_run`. The same
rules apply here.
Args:
name: The alias for this entry. Must not already exist in the project.
code: A self-contained Python script that binds `expr`.
prompt: Optional human-readable description.
Returns:
Same shape as catalog_run, plus `alias` and `version`.
"""
project = _resolve_active_project()
if get_alias(project, name) is not None:
return {"error": f"alias {name!r} already exists. Use catalog_revise to update it."}
out = _run_and_record(project, code, prompt, tool="catalog_create")
if "error" in out:
return out
info = set_alias(project, name, out["hash"], expect_exists=False)
notebook.append(project, name, markdown=prompt or "")
out.pop("_build", None)
out["alias"] = info["name"]
out["version"] = info["version"]
_notify("new_entry", content_hash=out["hash"], alias=name, version=info["version"])
_notify("notebook_changed")
return out
@mcp.tool()
@_tag_project
def catalog_revise(name: str, code: str, prompt: str = "") -> dict:
"""Persist a NEW VERSION of an existing alias.
The alias is repointed to the new content hash. The previous hash remains
in the catalog as a forensic artifact and is recorded in alias_history.
Code conventions and gotchas are documented in `catalog_run`. The same
rules apply here. Use `from_catalog(name)` to chain off the previous
version of the alias (or any other named entry).
Args:
name: An existing alias.
code: A self-contained Python script that binds `expr`.
prompt: Optional human-readable description of what changed.
"""
project = _resolve_active_project()
if get_alias(project, name) is None:
return {"error": f"alias {name!r} does not exist. Use catalog_create to create it."}
out = _run_and_record(project, code, prompt, tool="catalog_revise")
if "error" in out:
return out
info = set_alias(project, name, out["hash"], expect_exists=True)
out.pop("_build", None)
out["alias"] = info["name"]
out["version"] = info["version"]
_notify("new_entry", content_hash=out["hash"], alias=name, version=info["version"])
return out
@mcp.tool()
@_tag_project
def catalog_alias(hash: str, name: str) -> dict:
"""Promote an existing scratch (unnamed) entry to a named entry.
Use this when an entry was created via `catalog_run` and you decide
post-hoc that it deserves a name. Errors if the alias already exists.
"""
project = _resolve_active_project()
if not entry_dir(project, hash).exists():
return {"error": f"no catalog entry for hash {hash!r}"}
if get_alias(project, name) is not None:
return {"error": f"alias {name!r} already exists"}
info = set_alias(project, name, hash, expect_exists=False)
notebook.append(project, name)
_notify("alias_changed", content_hash=hash, alias=name, version=info["version"])
_notify("notebook_changed")
return {"hash": hash, "alias": name, "version": info["version"], "url": _entry_url(project, hash)}
@mcp.tool()
@_tag_project
def catalog_rename(old_name: str, new_name: str) -> dict:
"""Rename an existing alias. Preserves version history and notebook position."""
project = _resolve_active_project()
try:
info = rename_alias(project, old_name, new_name)
except AliasNotFound:
return {"error": f"alias {old_name!r} does not exist"}
except AliasExists:
return {"error": f"alias {new_name!r} already exists"}
notebook.rename_alias_in_cells(project, old_name, new_name)
_notify("alias_renamed", old=old_name, new=new_name, content_hash=info["hash"])
_notify("notebook_changed")
return info
@mcp.tool()
@_tag_project
def catalog_unalias(name: str) -> dict:
"""Remove an alias (the named entries become scratch again).
Catalog entries themselves remain; only the named handle is dropped. Any
notebook cells anchored on this alias are removed.
"""
project = _resolve_active_project()
if get_alias(project, name) is None:
return {"error": f"alias {name!r} does not exist"}
remove_alias(project, name)
n = notebook.remove_alias_from_cells(project, name)
_notify("alias_changed", alias=name)
if n:
_notify("notebook_changed")
return {"removed_alias": name, "notebook_cells_removed": n}
@mcp.tool()
@_tag_project
def notebook_reorder(cell_id: str, new_index: int) -> dict:
"""Move a notebook cell to a new position (0-based)."""
project = _resolve_active_project()
try:
cell = notebook.reorder(project, cell_id, new_index)
except CellNotFound:
return {"error": f"no cell with id {cell_id!r}"}
_notify("notebook_changed")
return {"cell": cell, "new_index": new_index}
@mcp.tool()
@_tag_project
def notebook_remove(cell_id: str) -> dict:
"""Remove a cell from the notebook. The underlying catalog entry is unaffected."""
project = _resolve_active_project()
try:
notebook.remove(project, cell_id)
except CellNotFound:
return {"error": f"no cell with id {cell_id!r}"}
_notify("notebook_changed")
return {"removed": cell_id}
@mcp.tool()
@_tag_project
def notebook_edit_markdown(cell_id: str, markdown: str) -> dict:
"""Replace the markdown text shown above a cell."""
project = _resolve_active_project()
try:
cell = notebook.edit_markdown(project, cell_id, markdown)
except CellNotFound:
return {"error": f"no cell with id {cell_id!r}"}
_notify("notebook_changed")
return {"cell": cell}
@mcp.tool()
@_tag_project
def catalog_chart(hash_or_alias: str, vega_spec: dict | str) -> dict:
"""Attach a Vega-Lite spec to a catalog entry.
The spec is rendered above the table on the entry's detail page via
vega-embed in the browser. The spec is stored as-is — no server-side
validation. Use the entry's columns by name; vega-embed will load the
parquet's data automatically (companion serves it as JSON to the chart).
Args:
hash_or_alias: Target entry. Aliases resolve to their latest hash.
vega_spec: A Vega-Lite v5 JSON spec (dict or JSON string).
Example spec::
{"mark": "bar",
"encoding": {"x": {"field": "region", "type": "nominal"},
"y": {"field": "total", "type": "quantitative"}}}
"""
project = _resolve_active_project()
target_hash = hash_or_alias
if not entry_dir(project, target_hash).exists():
resolved = get_alias(project, hash_or_alias)
if resolved is None:
return {"error": f"no entry for {hash_or_alias!r}"}
target_hash = resolved
try:
path = set_chart(project, target_hash, vega_spec)
except ChartSpecError as exc:
return {"error": str(exc)}
_notify("chart_attached", content_hash=target_hash)
return {"hash": target_hash, "spec_path": str(path)}
@mcp.tool()
@_tag_project
def catalog_diff(name: str, va: int = -2, vb: int = -1) -> dict:
"""Diff two versions of an alias.
Default compares the previous version to the current one (V_{n-1} vs V_n).
Returns code/schema/stats/head/keyed diff summaries.
Args:
name: An existing alias.
va, vb: 1-based version indices, or negative for from-end (-1 = latest).
"""
project = _resolve_active_project()
hashes = history_for(project, name)
if not hashes:
return {"error": f"alias {name!r} has no history"}
def resolve(v):
if v < 0:
v = len(hashes) + v + 1 # -1 -> last, -2 -> penultimate
if v < 1 or v > len(hashes):
return None
return v, hashes[v - 1]
a = resolve(va)
b = resolve(vb)
if a is None or b is None:
return {"error": f"version out of range; alias has {len(hashes)} versions"}
a_idx, a_hash = a
b_idx, b_hash = b
a_dir = entry_dir(project, a_hash)
b_dir = entry_dir(project, b_hash)
if not a_dir.exists() or not b_dir.exists():
return {"error": "one or both entries are missing on disk"}
# Diff the two entries' (cache-resolving) expressions — streaming xorq
# aggregates, and self-healing if a result.parquet was evicted.
from tallyman_xorq.result_cache import cached_result_expr
diff = full_diff(
a_dir,
b_dir,
a_label=f"V{a_idx}",
b_label=f"V{b_idx}",
backend="xorq",
a_expr=cached_result_expr(project, a_hash),
b_expr=cached_result_expr(project, b_hash),
)
return {
"alias": name,
"before": {"version": a_idx, "hash": a_hash},
"after": {"version": b_idx, "hash": b_hash},
"schema": diff["schema"],
"stats": diff["stats"],
"keyed_summary": ({k: v for k, v in diff["keyed"].items() if k != "table_html"} if diff["keyed"] else None),
}
@mcp.tool()
@_tag_project
def catalog_promote_diff(name: str, va: int = -2, vb: int = -1, alias: str | None = None) -> dict:
"""Promote a diff between two versions to a first-class catalog entry.
Creates a named catalog entry whose expression is the full outer-join
comparison of the two versions, with Buckaroo coloring preserved. The
entry can be post-processed, charted, and exported to a marimo notebook.
Args:
name: An existing alias.
va, vb: 1-based version indices, or negative for from-end (-1 = latest).
alias: Name for the new entry. Defaults to diff_{name}_v{va}_{vb}.
"""
import textwrap
from tallyman_companion.diff import compute_column_config_overrides
from tallyman_core.aliases import AliasExists, set_alias
from tallyman_core.catalog_state import checkpoint_catalog
from tallyman_core.display_configs import set_display_config
from tallyman_xorq import build_and_persist as _build_and_persist
from tallyman_xorq.build import BuildError
from tallyman_xorq.primary_key import diff_keys
from tallyman_xorq.result_cache import cached_result_expr
project = _resolve_active_project()
hashes = history_for(project, name)
if not hashes:
return {"error": f"alias {name!r} has no history"}
def _resolve(v):
if v < 0:
v = len(hashes) + v + 1
if v < 1 or v > len(hashes):
return None
return v, hashes[v - 1]
a = _resolve(va)
b = _resolve(vb)
if a is None or b is None:
return {"error": f"version out of range; alias has {len(hashes)} versions"}
a_idx, a_hash = a
b_idx, b_hash = b
keys = diff_keys(project, a_hash, b_hash) or []
if not keys:
return {"error": "no stable join key detected between the two versions; cannot build keyed diff"}
a_expr = cached_result_expr(project, a_hash)
b_expr = cached_result_expr(project, b_hash)
column_config_overrides = compute_column_config_overrides(a_expr.schema(), b_expr.schema(), keys)
target_alias = alias or f"diff_{name}_v{a_idx}_v{b_idx}"
keys_repr = repr(keys)
code = textwrap.dedent(f"""\
# auto-generated — diff of {name} V{a_idx} → V{b_idx}
# a_hash: {a_hash}
# b_hash: {b_hash}
from tallyman_companion.diff import build_diff_expr
expr = build_diff_expr(
a_hash={a_hash!r},
b_hash={b_hash!r},
keys={keys_repr},
)
""")
try:
result = _build_and_persist(project, code, prompt=f"promote diff {name} V{a_idx}→V{b_idx}")
except BuildError as exc:
return {"error": str(exc)}
try:
set_alias(project, target_alias, result.content_hash, expect_exists=False)
except AliasExists:
set_alias(project, target_alias, result.content_hash)
set_display_config(
project,
result.content_hash,
{
"column_config_overrides": column_config_overrides,
"diff_provenance": {
"source_alias": name,
"va": a_idx,
"vb": b_idx,
"a_hash": a_hash,
"b_hash": b_hash,
"keys": keys,
},
},
)
checkpoint_catalog(project, f"tallyman: catalog_promote_diff {name} V{a_idx}→V{b_idx}")
_notify("entry_added", content_hash=result.content_hash)
return {
"alias": target_alias,
"hash": result.content_hash,
"source_alias": name,
"va": a_idx,
"vb": b_idx,
"a_hash": a_hash,
"b_hash": b_hash,
"keys": keys,
"row_count": result.row_count,
}
@mcp.tool()
@_tag_project
def catalog_add_summary_stat(name: str, source: str) -> dict:
"""Register a project-authored column summary stat.
``source`` must define a callable ``compute(col)`` that takes an ibis
column expression and returns an ibis scalar expression. The function
is validated by dry-running it against a 1-row in-memory table before
the file lands on disk — syntax / type / shape errors surface in the
tool response, not later in the buckaroo log.
The stat is hot-reloaded into all open buckaroo sessions immediately
after this call; no page refresh is needed.
``source`` example::
def compute(col):
return (col.cast("string").contains("@").sum() / col.count())
The function name MUST be ``compute``; the *filename* is ``<name>.py``
under ``<project>/stats/``, and the file's stem is the stat's key in
every column's stats panel.
PINNED-ROW NOTE: adding a stat makes it visible in the "summary" stats
panel only. To show it as a pinned row in the *main* table view, call
``catalog_add_display_klass`` immediately after with a class that
subclasses ``DefaultMainStyling`` and extends ``pinned_rows``::
class MainWith<Name>(DefaultMainStyling):
df_display_name = "main"
requires_summary = ["histogram", "is_numeric", "dtype", "_type", "<name>"]
pinned_rows = [
{'primary_key_val': 'dtype', 'displayer_args': {'displayer': 'obj'}},
{'primary_key_val': 'histogram', 'displayer_args': {'displayer': 'histogram'}},
{'primary_key_val': '<name>', 'displayer_args': {'displayer': 'inherit'}},
]
Args:
name: filename stem (no ``.py``). Must be a valid python identifier.
source: the file body. Must define ``compute(col) -> ibis_expr``.
"""
project = _resolve_active_project()
try:
path = write_stat(project, name, source)
except StatSourceError as exc:
return {"error": str(exc)}
_notify("summary_stat_changed", extra={"action": "add", "name": name})
return {"name": name, "path": str(path)}
@mcp.tool()
@_tag_project
def catalog_remove_summary_stat(name: str) -> dict:
"""Soft-delete a project-authored stat.
Moves ``stats/<name>.py`` to ``stats/_disabled/<name>.py``. The
``_disabled/`` subdirectory is ignored by buckaroo's scanner (the
``_`` prefix is the convention) so the stat disappears from new
sessions on the next load. To hard-delete, ``rm`` the file under
``stats/_disabled/`` manually.
"""
project = _resolve_active_project()
new_path = remove_stat(project, name)
if new_path is None:
return {"error": f"no stat named {name!r}"}
_notify("summary_stat_changed", extra={"action": "remove", "name": name})
return {"name": name, "moved_to": str(new_path)}
@mcp.tool()
@_tag_project
def catalog_list_summary_stats() -> list[dict]:
"""List every project-authored summary stat (active + disabled).
Returns ``[{name, path, source, disabled}]`` sorted with active stats
first, then disabled (parked) ones.
"""
project = _resolve_active_project()
return list_stats(project)
@mcp.tool()
@_tag_project
def catalog_add_display_klass(name: str, source: str) -> dict:
"""Register a project-authored display klass (ColAnalysis subclass).
Display klasses control how columns are rendered and which rows are
pinned at the top of a buckaroo table. They are exec'd with
``ColAnalysis``, ``DefaultMainStyling``, ``DefaultSummaryStatsStyling``,
and ``StylingAnalysis`` already in scope — no imports needed.
The klass is hot-reloaded into all open buckaroo sessions immediately
after this call; no page refresh is needed.
PRIMARY USE CASE — adding a stat as a pinned row in the main view:
after calling ``catalog_add_summary_stat`` for a new stat, call this
tool to extend ``DefaultMainStyling`` so the stat appears as a frozen
row at the top of every catalog entry's main table::
class MainWithMostFreq(DefaultMainStyling):
df_display_name = "main"
requires_summary = ["histogram", "is_numeric", "dtype", "_type", "most_freq"]
pinned_rows = [
{'primary_key_val': 'dtype', 'displayer_args': {'displayer': 'obj'}},
{'primary_key_val': 'histogram', 'displayer_args': {'displayer': 'histogram'}},
{'primary_key_val': 'most_freq', 'displayer_args': {'displayer': 'inherit'}},
]
Pinned-row displayer options:
- ``'inherit'`` — use the column's natural type displayer (recommended)
- ``'obj'`` — render as a raw Python object (fallback)
- ``'float'`` — fixed decimal places (for numeric stats)
- ``'histogram'``— histogram bar (only for the ``histogram`` stat key)
The ``df_display_name`` must match an existing display slot:
- ``"main"`` — the primary table view (override DefaultMainStyling)
- ``"summary"`` — the all-stats panel (override DefaultSummaryStatsStyling)
Args:
name: filename stem (no ``.py``). Must be a valid Python identifier.
source: class definition. Must produce at least one ColAnalysis
subclass with a string ``df_display_name`` attribute.
"""
project = _resolve_active_project()
try:
path = write_display_klass(project, name, source)
except DisplayKlassError as exc:
return {"error": str(exc)}
_notify("display_changed", extra={"action": "add", "name": name})
return {"name": name, "path": str(path)}
@mcp.tool()