databricks-mason 0.1.0.dev0__py3-none-any.whl

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.
@@ -0,0 +1,347 @@
1
+ """`mason memory` — manage workspace-scoped managed memory stores and entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import click
8
+
9
+ from databricks_mason import render, timefmt
10
+ from databricks_mason.render import field
11
+
12
+ _BREADCRUMB = "Agent Memory"
13
+
14
+
15
+ def _store_id(store: dict) -> str:
16
+ name = field(store, "name") or ""
17
+ return name.split("/")[-1] if name else "—"
18
+
19
+
20
+ def _truncate(value: Any, length: int = 60) -> str:
21
+ text = "" if value is None else str(value)
22
+ return text if len(text) <= length else text[: length - 1] + "…"
23
+
24
+
25
+ # --- group ------------------------------------------------------------------
26
+
27
+
28
+ @click.group()
29
+ def memory() -> None:
30
+ """Manage agent memory stores and entries (/api/agents/v1/memory-stores)."""
31
+
32
+
33
+ @memory.group()
34
+ def stores() -> None:
35
+ """Workspace-scoped managed memory stores."""
36
+
37
+
38
+ @memory.group()
39
+ def entries() -> None:
40
+ """Memory entries within a store, partitioned by actor."""
41
+
42
+
43
+ # --- stores -----------------------------------------------------------------
44
+
45
+
46
+ def _store_starter_code(obj, store: dict) -> list[tuple[str, str, str]]:
47
+ store_id = _store_id(store)
48
+ name = field(store, "name") or f"memory-stores/{store_id}"
49
+ return [
50
+ (
51
+ "curl",
52
+ "bash",
53
+ f"""
54
+ curl -X POST "{obj.client().host}/api/agents/v1/{name}/entries" \\
55
+ -H "Authorization: Bearer $DATABRICKS_TOKEN" -H "Content-Type: application/json" \\
56
+ -d '{{"actor_id": "alice", "path": "/preferences/style.md", "content": "Terse, code first."}}'
57
+ """,
58
+ ),
59
+ (
60
+ "mason",
61
+ "bash",
62
+ f"""
63
+ mason memory entries create --store {store_id} \\
64
+ --actor-id alice --path /preferences/style.md --content "Terse, code first."
65
+ mason memory entries search --store {store_id} --actor-id alice --query "style"
66
+ """,
67
+ ),
68
+ ]
69
+
70
+
71
+ def _render_store_detail(obj, store: dict) -> None:
72
+ render.detail(
73
+ _BREADCRUMB,
74
+ field(store, "display_name") or _store_id(store),
75
+ {
76
+ "Name": field(store, "name"),
77
+ "Store ID": _store_id(store),
78
+ "Workspace": field(store, "workspace_id"),
79
+ "Owner": field(store, "owner_user_id"),
80
+ "Storage": render.field(field(store, "storage_backend") or {}, "backend_id"),
81
+ "Description": field(store, "description"),
82
+ "Created": timefmt.absolute(field(store, "created_at")),
83
+ "Updated": timefmt.absolute(field(store, "updated_at")),
84
+ },
85
+ status="ACTIVE",
86
+ snippets=_store_starter_code(obj, store),
87
+ )
88
+
89
+
90
+ @stores.command("create")
91
+ @click.option("--display-name", required=True, help="Workspace-unique display name.")
92
+ @click.option("--description", default=None)
93
+ @click.pass_obj
94
+ def stores_create(obj, display_name, description) -> None:
95
+ """Create a memory store."""
96
+ data = obj.client().create_memory_store(display_name, description)
97
+ if obj.output == "json":
98
+ render.emit_json(data)
99
+ return
100
+ store_id = _store_id(data)
101
+ render.success(
102
+ f"Created memory store '{display_name}'",
103
+ fields={"Store ID": store_id, "Name": field(data, "name")},
104
+ next_steps=[
105
+ f"mason memory entries create --store {store_id} --actor-id <id> --path </p>",
106
+ f"mason memory stores get {store_id}",
107
+ ],
108
+ )
109
+
110
+
111
+ @stores.command("list")
112
+ @click.option("--page-size", type=int, default=None)
113
+ @click.option("--page-token", default=None)
114
+ @click.pass_obj
115
+ def stores_list(obj, page_size, page_token) -> None:
116
+ """List memory stores in the workspace."""
117
+ data = obj.client().list_memory_stores(page_size, page_token)
118
+ if obj.output == "json":
119
+ render.emit_json(data)
120
+ return
121
+ items = field(data, "managed_memory_stores") or []
122
+ rows = [
123
+ [
124
+ field(s, "display_name"),
125
+ _store_id(s),
126
+ timefmt.relative(field(s, "created_at")),
127
+ timefmt.relative(field(s, "updated_at")),
128
+ _truncate(field(s, "description"), 40),
129
+ ]
130
+ for s in items
131
+ ]
132
+ render.resource_table(
133
+ "Managed Memory Stores",
134
+ [
135
+ ("Name", "left"),
136
+ ("Store ID", "left"),
137
+ ("Created", "left"),
138
+ ("Updated", "left"),
139
+ ("Description", "left"),
140
+ ],
141
+ rows,
142
+ subtitle=_page_note(data),
143
+ )
144
+
145
+
146
+ @stores.command("get")
147
+ @click.argument("name")
148
+ @click.pass_obj
149
+ def stores_get(obj, name) -> None:
150
+ """Get a memory store by id or resource name."""
151
+ data = obj.client().get_memory_store(name)
152
+ if obj.output == "json":
153
+ render.emit_json(data)
154
+ return
155
+ _render_store_detail(obj, data)
156
+
157
+
158
+ @stores.command("update")
159
+ @click.argument("name")
160
+ @click.option("--display-name", default=None)
161
+ @click.option("--description", default=None)
162
+ @click.pass_obj
163
+ def stores_update(obj, name, display_name, description) -> None:
164
+ """Update a store's display name and/or description."""
165
+ data = obj.client().update_memory_store(name, display_name, description)
166
+ if obj.output == "json":
167
+ render.emit_json(data)
168
+ return
169
+ _render_store_detail(obj, data)
170
+
171
+
172
+ @stores.command("delete")
173
+ @click.argument("name")
174
+ @click.pass_obj
175
+ def stores_delete(obj, name) -> None:
176
+ """Delete (soft-delete) a memory store."""
177
+ obj.client().delete_memory_store(name)
178
+ if obj.output == "json":
179
+ render.emit_json({"deleted": name})
180
+ return
181
+ render.success(f"Deleted memory store '{name}'")
182
+
183
+
184
+ # --- entries ----------------------------------------------------------------
185
+
186
+
187
+ def _render_entry_detail(entry: dict) -> None:
188
+ render.detail(
189
+ f"{_BREADCRUMB} Entry",
190
+ field(entry, "path") or "—",
191
+ {
192
+ "Name": field(entry, "name"),
193
+ "Actor": field(entry, "actor_id"),
194
+ "Session": field(entry, "session_id"),
195
+ "Path": field(entry, "path"),
196
+ "Source": field(entry, "source_type"),
197
+ "Description": field(entry, "description"),
198
+ "Content": field(entry, "content"),
199
+ "Created": timefmt.absolute(field(entry, "create_time")),
200
+ "Updated": timefmt.absolute(field(entry, "update_time")),
201
+ },
202
+ status="ACTIVE",
203
+ )
204
+
205
+
206
+ @entries.command("create")
207
+ @click.option("--store", required=True, help="Store id or resource name.")
208
+ @click.option("--actor-id", required=True)
209
+ @click.option("--path", required=True, help="Absolute path, e.g. /preferences/style.md.")
210
+ @click.option("--content", default=None)
211
+ @click.option("--description", default=None)
212
+ @click.option("--session-id", default=None)
213
+ @click.option(
214
+ "--source-type",
215
+ default=None,
216
+ type=click.Choice(
217
+ ["MANAGED_MEMORY_ENTRY_SOURCE_TYPE_AGENT", "MANAGED_MEMORY_ENTRY_SOURCE_TYPE_UNSPECIFIED"]
218
+ ),
219
+ )
220
+ @click.pass_obj
221
+ def entries_create(
222
+ obj, store, actor_id, path, content, description, session_id, source_type
223
+ ) -> None:
224
+ """Create a memory entry."""
225
+ data = obj.client().create_memory_entry(
226
+ store, actor_id, path, content, description, session_id, source_type
227
+ )
228
+ if obj.output == "json":
229
+ render.emit_json(data)
230
+ return
231
+ render.success(f"Created memory entry '{path}'", fields={"Name": field(data, "name")})
232
+
233
+
234
+ @entries.command("get")
235
+ @click.option("--store", required=True)
236
+ @click.argument("entry")
237
+ @click.pass_obj
238
+ def entries_get(obj, store, entry) -> None:
239
+ """Get an entry by id or resource name (includes content)."""
240
+ data = obj.client().get_memory_entry(store, entry)
241
+ if obj.output == "json":
242
+ render.emit_json(data)
243
+ return
244
+ _render_entry_detail(data)
245
+
246
+
247
+ @entries.command("list")
248
+ @click.option("--store", required=True)
249
+ @click.option("--actor-id", required=True, help="Required partition key.")
250
+ @click.option("--path-prefix", default=None)
251
+ @click.option("--session-id", default=None)
252
+ @click.option("--page-size", type=int, default=None)
253
+ @click.option("--page-token", default=None)
254
+ @click.pass_obj
255
+ def entries_list(obj, store, actor_id, path_prefix, session_id, page_size, page_token) -> None:
256
+ """List entries for an actor (content omitted)."""
257
+ data = obj.client().list_memory_entries(
258
+ store, actor_id, path_prefix, session_id, page_size, page_token
259
+ )
260
+ if obj.output == "json":
261
+ render.emit_json(data)
262
+ return
263
+ items = field(data, "managed_memory_entries") or []
264
+ rows = [
265
+ [
266
+ field(e, "path"),
267
+ field(e, "actor_id"),
268
+ field(e, "session_id"),
269
+ _truncate(field(e, "description"), 40),
270
+ timefmt.relative(field(e, "update_time")),
271
+ ]
272
+ for e in items
273
+ ]
274
+ render.resource_table(
275
+ f"Memory Entries · actor {actor_id}",
276
+ [
277
+ ("Path", "left"),
278
+ ("Actor", "left"),
279
+ ("Session", "left"),
280
+ ("Description", "left"),
281
+ ("Updated", "left"),
282
+ ],
283
+ rows,
284
+ subtitle=_page_note(data),
285
+ )
286
+
287
+
288
+ @entries.command("search")
289
+ @click.option("--store", required=True)
290
+ @click.option("--actor-id", required=True)
291
+ @click.option("--query", required=True)
292
+ @click.option("--limit", type=int, default=None)
293
+ @click.pass_obj
294
+ def entries_search(obj, store, actor_id, query, limit) -> None:
295
+ """Full-text search an actor's entries, ranked (includes content)."""
296
+ data = obj.client().search_memory_entries(store, actor_id, query, limit)
297
+ if obj.output == "json":
298
+ render.emit_json(data)
299
+ return
300
+ items = field(data, "managed_memory_entries") or []
301
+ rows = [
302
+ [
303
+ field(e, "path"),
304
+ field(e, "actor_id"),
305
+ _truncate(field(e, "content"), 50),
306
+ timefmt.relative(field(e, "update_time")),
307
+ ]
308
+ for e in items
309
+ ]
310
+ render.resource_table(
311
+ f"Memory Search · '{query}'",
312
+ [("Path", "left"), ("Actor", "left"), ("Content", "left"), ("Updated", "left")],
313
+ rows,
314
+ )
315
+
316
+
317
+ @entries.command("update")
318
+ @click.option("--store", required=True)
319
+ @click.argument("entry")
320
+ @click.option("--content", default=None)
321
+ @click.option("--description", default=None)
322
+ @click.pass_obj
323
+ def entries_update(obj, store, entry, content, description) -> None:
324
+ """Update an entry's content and/or description."""
325
+ data = obj.client().update_memory_entry(store, entry, content, description)
326
+ if obj.output == "json":
327
+ render.emit_json(data)
328
+ return
329
+ _render_entry_detail(data)
330
+
331
+
332
+ @entries.command("delete")
333
+ @click.option("--store", required=True)
334
+ @click.argument("entry")
335
+ @click.pass_obj
336
+ def entries_delete(obj, store, entry) -> None:
337
+ """Delete a memory entry."""
338
+ obj.client().delete_memory_entry(store, entry)
339
+ if obj.output == "json":
340
+ render.emit_json({"deleted": entry})
341
+ return
342
+ render.success(f"Deleted memory entry '{entry}'")
343
+
344
+
345
+ def _page_note(data: dict) -> str | None:
346
+ token = field(data, "next_page_token")
347
+ return f"More results available — pass --page-token {token}" if token else None
@@ -0,0 +1,185 @@
1
+ """Terminal presentation layer for the Mason CLI.
2
+
3
+ Rendering is centralized so every `list`/`get`/`create` command looks consistent.
4
+ Functions accept an optional `console` for testability; default is stdout.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any, Iterable, Literal, Optional, Sequence
11
+
12
+ import click
13
+ from rich import box
14
+ from rich.console import Console, Group, RenderableType
15
+ from rich.panel import Panel
16
+ from rich.syntax import Syntax
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ # Databricks brand accent.
21
+ ACCENT = "#FF3621"
22
+ MUTED = "grey62"
23
+
24
+ _stdout = Console()
25
+
26
+
27
+ def console() -> Console:
28
+ return _stdout
29
+
30
+
31
+ # --- small helpers -----------------------------------------------------------
32
+
33
+
34
+ def field(obj: dict, name: str) -> Any:
35
+ """Read a field tolerating snake_case or camelCase JSON keys."""
36
+ if name in obj:
37
+ return obj[name]
38
+ parts = name.split("_")
39
+ camel = parts[0] + "".join(p.title() for p in parts[1:])
40
+ return obj.get(camel)
41
+
42
+
43
+ def emit_json(data: Any) -> None:
44
+ """Print raw JSON for `--output json` (stable, pipe-friendly)."""
45
+ click.echo(json.dumps(data, indent=2, default=str))
46
+
47
+
48
+ def status_pill(status: Optional[str]) -> Text:
49
+ """Create a colored ●/○ status indicator."""
50
+ value = (status or "").strip().upper()
51
+ if value in {"ACTIVE", "RUNNING", "READY"}:
52
+ return Text("● ", style="green") + Text(value.title(), style="green")
53
+ if value in {"PENDING", "CREATING", "STARTING", "DEPLOYING"}:
54
+ return Text("○ ", style="yellow") + Text(value.title(), style="yellow")
55
+ if value in {"DISABLED", "DELETED", "STOPPED", "ERROR", "FAILED"}:
56
+ return Text("⨯ ", style="red") + Text(value.title(), style="red")
57
+ return Text("● ", style=MUTED) + Text(value.title() or "Unknown", style=MUTED)
58
+
59
+
60
+ # --- list view ---------------------------------------------------------------
61
+
62
+
63
+ def resource_table(
64
+ title: str,
65
+ columns: Sequence[tuple[str, Literal["default", "left", "center", "right", "full"]]],
66
+ rows: Iterable[Sequence[Any]],
67
+ *,
68
+ subtitle: Optional[str] = None,
69
+ con: Optional[Console] = None,
70
+ ) -> None:
71
+ """Render a titled list table.
72
+
73
+ `columns` is a sequence of (header, justify) where justify is left/right/center.
74
+ """
75
+ con = con or _stdout
76
+ rows = list(rows)
77
+
78
+ con.print()
79
+ con.print(Text(title, style=f"bold {ACCENT}"))
80
+ if subtitle:
81
+ con.print(Text(subtitle, style=MUTED))
82
+
83
+ table = Table(box=box.SIMPLE_HEAD, expand=False, pad_edge=False, show_edge=False)
84
+ for header, justify in columns:
85
+ table.add_column(header.upper(), justify=justify, header_style=f"bold {MUTED}")
86
+ for row in rows:
87
+ table.add_row(*[_cell(v) for v in row])
88
+ con.print(table)
89
+
90
+ con.print(Text(f"{len(rows)} item{'s' if len(rows) != 1 else ''}", style=MUTED))
91
+
92
+
93
+ def _cell(value: Any) -> Any:
94
+ if isinstance(value, Text):
95
+ return value
96
+ if value is None:
97
+ return Text("—", style=MUTED)
98
+ return str(value)
99
+
100
+
101
+ # --- detail view (aig-endpoint.png) ------------------------------------------
102
+
103
+
104
+ def detail(
105
+ breadcrumb: str,
106
+ name: str,
107
+ fields: dict[str, Any],
108
+ *,
109
+ status: Optional[str] = None,
110
+ snippets: Optional[Sequence[tuple[str, str, str]]] = None,
111
+ con: Optional[Console] = None,
112
+ ) -> None:
113
+ """Render a resource detail page.
114
+
115
+ `breadcrumb` is the section label ("Agent Memory"); `fields` is an ordered
116
+ key -> value map for the details rail. `snippets` is a sequence of
117
+ (label, lexer, code) blocks shown under a "Starter code" panel.
118
+ """
119
+ con = con or _stdout
120
+
121
+ con.print()
122
+ con.print(Text(f"{breadcrumb} › ", style=MUTED) + Text(name, style="bold"))
123
+ if status is not None:
124
+ con.print(status_pill(status))
125
+ con.print()
126
+
127
+ grid = Table.grid(padding=(0, 3))
128
+ grid.add_column(style=MUTED, justify="left")
129
+ grid.add_column(justify="left")
130
+ for key, value in fields.items():
131
+ grid.add_row(key, _cell(value))
132
+ con.print(grid)
133
+
134
+ if snippets:
135
+ con.print()
136
+ con.print(
137
+ Panel(
138
+ _snippet_group(snippets),
139
+ title="Starter code",
140
+ title_align="left",
141
+ border_style=MUTED,
142
+ box=box.ROUNDED,
143
+ )
144
+ )
145
+
146
+
147
+ def _snippet_group(snippets: Sequence[tuple[str, str, str]]) -> RenderableType:
148
+ parts: list[RenderableType] = []
149
+ for i, (label, lexer, code) in enumerate(snippets):
150
+ if i:
151
+ parts.append(Text())
152
+ parts.append(Text(label, style=f"bold {MUTED}"))
153
+ parts.append(Syntax(code.strip(), lexer, background_color="default", word_wrap=True))
154
+ return Group(*parts)
155
+
156
+
157
+ # --- success / next steps (the "Set up" cards) -------------------------------
158
+
159
+
160
+ def success(
161
+ title: str,
162
+ *,
163
+ fields: Optional[dict[str, Any]] = None,
164
+ next_steps: Optional[Sequence[str]] = None,
165
+ con: Optional[Console] = None,
166
+ ) -> None:
167
+ """A green success panel with optional details and a Next steps list."""
168
+ con = con or _stdout
169
+ body: list[RenderableType] = [Text("✓ ", style="green") + Text(title, style="bold")]
170
+
171
+ if fields:
172
+ grid = Table.grid(padding=(0, 3))
173
+ grid.add_column(style=MUTED)
174
+ grid.add_column()
175
+ for key, value in fields.items():
176
+ grid.add_row(key, _cell(value))
177
+ body.append(grid)
178
+
179
+ if next_steps:
180
+ body.append(Text("Next steps", style=f"bold {MUTED}"))
181
+ for step in next_steps:
182
+ body.append(Text(" → ", style=ACCENT) + Text(step))
183
+
184
+ con.print()
185
+ con.print(Panel(Group(*body), border_style="green", box=box.ROUNDED))