stcode 0.0.1a0__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.
Files changed (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,493 @@
1
+ """
2
+ Session — one append-only JSONL file, three readers.
3
+
4
+ `~/.stcode/sessions/<id>.jsonl`. Every event goes in, including the ones the model
5
+ never sees:
6
+
7
+ * `messages()` — history for the gateway, folded into provider `Message`s.
8
+ * the file itself — the trajectory. Agent → tool → sub-agent leaves no natural stack
9
+ trace, so this *is* the stack trace.
10
+ * `tail()` — raw records for the supervisor, which needs what the model is not shown.
11
+
12
+ **Append-only, and that is load-bearing.** No rewriting, no branch, no fork: `cp
13
+ session.jsonl` is the branching feature. It is also what makes `append()` one `write`
14
+ + `flush`, and so safe to call synchronously from the agent loop — the one exception to
15
+ "no blocking I/O in the loop", because ~20µs of buffered write beats the machinery to
16
+ avoid it.
17
+
18
+ Two consequences of that rule, both here:
19
+
20
+ * **`defer=True` holds the file back until the first message.** A session id can exist
21
+ with nothing behind it, which is what lets the UI's `/clear` be "start another one"
22
+ instead of a directory of empty transcripts.
23
+ * **A setting is changed by appending another `meta` record**, never by editing the
24
+ first one. `meta()` is what the session started as; `overrides()` is what has been
25
+ changed since. That is how `/model` reaches a running agent.
26
+
27
+ Swapping to a live database later replaces this class; `Agent` sees the same four
28
+ methods. Do not abstract for that now — a class with four methods **is** the
29
+ abstraction.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import builtins
35
+ import json
36
+ import os
37
+ from datetime import datetime, timezone
38
+ from pathlib import Path
39
+ from typing import Any, Iterator, Sequence, TextIO
40
+
41
+ from stcode.core.common.ids import new_id
42
+ from stcode.core.providers.types import (
43
+ ContentBlock,
44
+ Message,
45
+ TextBlock,
46
+ ToolResultBlock,
47
+ ToolUseBlock,
48
+ )
49
+
50
+ DEFAULT_SESSION_DIR = "~/.stcode/sessions"
51
+
52
+ MODEL_VISIBLE = frozenset({"user", "assistant", "tool_call", "tool_result", "supervisor", "inbox"})
53
+ """Record types `messages()` folds into history. `meta`, `usage` and `error` are for
54
+ the human reading the trajectory and for the supervisor — sending the model its own
55
+ token counts is paying to tell it something it cannot act on."""
56
+
57
+
58
+ META_BOOKKEEPING = frozenset({"type", "ts", "id", "parent", "agent_name"})
59
+ """Keys a later `meta` record carries but that are not settings. `overrides()` drops
60
+ them, so a client can hand the whole dict to the gateway without sieving it first."""
61
+
62
+
63
+ def sessions_dir(directory: str | Path | None = None) -> Path:
64
+ return Path(os.path.expanduser(str(directory or DEFAULT_SESSION_DIR)))
65
+
66
+
67
+ class Session:
68
+ """An append-only JSONL transcript of one agent's work.
69
+
70
+ `builtins.list[...]` in the annotations below is not decoration: this class has a
71
+ classmethod called `list`, which shadows the builtin inside the class body, so a
72
+ bare `list[dict[str, Any]]` on a method resolves to the classmethod and every
73
+ annotation using it is quietly wrong. The name `Session.list()` is worth keeping;
74
+ the four extra characters are the price.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ path: Path,
80
+ *,
81
+ records: builtins.list[dict[str, Any]] | None = None,
82
+ defer: bool = False,
83
+ ) -> None:
84
+ """Open `path`, adopting whatever is already in it.
85
+
86
+ Appending to a file whose earlier content was never loaded breaks the premise:
87
+ `messages()` would describe a shorter conversation than the file does, and the
88
+ trajectory would read as a continuation the model never saw. Pointing at an
89
+ existing file means resuming it — the file is the truth, not the object. Pass
90
+ `records` only when you have already read them.
91
+
92
+ `defer=True` does not open or create anything. The first record that is not
93
+ `meta` does both, and until then this is an id with a `meta` record behind it.
94
+ """
95
+ self.path = path
96
+ self.id = path.stem
97
+ if records is None:
98
+ records = list(read_records(path)) if path.is_file() else []
99
+ self._records: builtins.list[dict[str, Any]] = records
100
+ self._held: builtins.list[dict[str, Any]] = []
101
+ """Records appended before the file was opened. Written, in order, by `_start`."""
102
+
103
+ self._chosen: dict[str, Any] = {}
104
+ """Settings changed through `set_meta` **before** the session had a file.
105
+
106
+ Those merge into the pending first `meta` record rather than appending a second
107
+ one, which keeps the file clean — but `overrides()` reads the records *after*
108
+ the first, so without this the change would be invisible to it. A `/effort`
109
+ chosen before the first message would then apply to no model call at all, which
110
+ is indistinguishable from `/effort` doing nothing.
111
+ """
112
+
113
+ self._file: TextIO | None = None
114
+ if not defer:
115
+ self._start()
116
+
117
+ @property
118
+ def started(self) -> bool:
119
+ """Whether this session has a file. False only for a deferred one that nobody
120
+ has said anything to."""
121
+ return self._file is not None
122
+
123
+ def _start(self) -> None:
124
+ """Open the file and flush anything held back. Idempotent by the caller's check.
125
+
126
+ Line-buffered and held open: a turn with twenty tool calls produces sixty
127
+ events, and open/close per event is not worth paying.
128
+ """
129
+ self.path.parent.mkdir(parents=True, exist_ok=True)
130
+ self._file = self.path.open("a", encoding="utf-8", buffering=1)
131
+ held, self._held = self._held, []
132
+ for record in held:
133
+ self._write(record)
134
+
135
+ def _write(self, record: dict[str, Any]) -> None:
136
+ assert self._file is not None
137
+ self._file.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
138
+
139
+ # ---- construction ----
140
+
141
+ @classmethod
142
+ def create(
143
+ cls,
144
+ *,
145
+ cwd: str | Path | None = None,
146
+ role: str = "",
147
+ model: str = "",
148
+ directory: str | Path | None = None,
149
+ defer: bool = False,
150
+ **meta: Any,
151
+ ) -> "Session":
152
+ """Start a new session with the `meta` record that `list()` reads back.
153
+
154
+ `defer=True` keeps that record in memory and writes no file until the first
155
+ message. The id is real either way — the daemon registers it, a client is
156
+ handed it — so nothing downstream has to know which kind it got.
157
+ """
158
+ root = sessions_dir(directory)
159
+ session = cls(root / f"{new_id()}.jsonl", defer=defer)
160
+ session.append(
161
+ type="meta",
162
+ id=session.id,
163
+ cwd=str(Path(cwd).expanduser() if cwd else Path.cwd()),
164
+ role=role,
165
+ model=model,
166
+ **meta,
167
+ )
168
+ return session
169
+
170
+ @classmethod
171
+ def resume(cls, session_id: str, *, directory: str | Path | None = None) -> "Session":
172
+ """Reopen an existing session and keep appending to it."""
173
+ path = sessions_dir(directory) / f"{session_id}.jsonl"
174
+ if not path.is_file():
175
+ raise FileNotFoundError(f"No session {session_id!r} in {path.parent}.")
176
+ return cls(path)
177
+
178
+ @classmethod
179
+ def list(
180
+ cls, limit: int = 20, *, directory: str | Path | None = None
181
+ ) -> builtins.list[dict[str, Any]]:
182
+ """The `meta` record of each session, newest first.
183
+
184
+ A directory listing and one line per file. No index, no SQLite: ULID filenames
185
+ already sort by time, so the newest N are the last N names.
186
+ """
187
+ root = sessions_dir(directory)
188
+ if not root.is_dir():
189
+ return []
190
+ summaries: builtins.list[dict[str, Any]] = []
191
+ for path in sorted(root.glob("*.jsonl"), reverse=True)[:limit]:
192
+ meta = _first_meta(path)
193
+ meta.setdefault("id", path.stem)
194
+ meta["path"] = str(path)
195
+ meta["summary"] = _summary(path)
196
+ summaries.append(meta)
197
+ return summaries
198
+
199
+ def child(self, name: str) -> "Session":
200
+ """A separate session file for a sub-agent, linked back to this one.
201
+
202
+ A separate *file*: interleaving a child's tool calls into the parent's
203
+ transcript would make `messages()` produce a conversation neither agent had.
204
+ The link is two fields in the child's `meta` — `parent` and `agent_name` —
205
+ enough to reassemble the tree.
206
+
207
+ From `meta()` and deliberately **not** `overrides()`: a `/model` override is a
208
+ statement about this conversation, and inheriting it would pin
209
+ `task(difficulty="low")` to the expensive model and make tiers decorative.
210
+
211
+ Deferred, like any other session, so a sub-agent that fails before it says
212
+ anything leaves no file.
213
+ """
214
+ return Session.create(
215
+ cwd=self.meta().get("cwd"),
216
+ role=self.meta().get("role", ""),
217
+ model=self.meta().get("model", ""),
218
+ directory=self.path.parent,
219
+ defer=True,
220
+ parent=self.id,
221
+ agent_name=name,
222
+ )
223
+
224
+ # ---- writing ----
225
+
226
+ def append(self, **record: Any) -> dict[str, Any]:
227
+ """Append one record. Synchronous and flushed — see the module docstring.
228
+
229
+ `type` is required; `ts` is stamped here so no caller has to remember to.
230
+ """
231
+ if "type" not in record:
232
+ raise ValueError("a session record needs a `type`")
233
+ record.setdefault("ts", datetime.now(timezone.utc).isoformat(timespec="milliseconds"))
234
+ self._records.append(record)
235
+ if self._file is None:
236
+ # A `meta` record does not start a session; something being *said* does.
237
+ # Held in order, so the file still opens with its meta line.
238
+ if record["type"] == "meta":
239
+ self._held.append(record)
240
+ return record
241
+ self._start()
242
+ self._write(record)
243
+ return record
244
+
245
+ def set_meta(self, **fields: Any) -> dict[str, Any]:
246
+ """Change a session setting — the model, the provider, the reasoning effort.
247
+
248
+ On a session that has started this **appends** another `meta` record: rule 4,
249
+ nothing is rewritten, and the transcript says when the change happened.
250
+
251
+ On one that has not, it merges into the pending meta instead. Nothing has been
252
+ written, so there is nothing to rewrite — and a file whose first two lines are
253
+ both `meta` is a file that did not need the first one.
254
+ """
255
+ if not self.started:
256
+ pending = next((r for r in self._records if r.get("type") == "meta"), None)
257
+ if pending is not None:
258
+ pending.update(fields)
259
+ self._chosen.update(fields)
260
+ return pending
261
+ return self.append(type="meta", **fields)
262
+
263
+ # ---- reading ----
264
+
265
+ def meta(self) -> dict[str, Any]:
266
+ """The **first** `meta` record: what this session started as.
267
+
268
+ Not merged with `overrides()`. A caller that wants the effective settings asks
269
+ for both and says which is which — `SessionRunner.describe()` does exactly
270
+ that — because "the model it started with" and "the model it is on now" are
271
+ different questions and a merged dict can only answer one of them.
272
+ """
273
+ records = self._meta_records()
274
+ return records[0] if records else {}
275
+
276
+ def overrides(self) -> dict[str, Any]:
277
+ """Every `meta` record **after** the first, merged, later wins.
278
+
279
+ The split is the whole mechanism: `meta()` is what the session started as and
280
+ what `list()` shows, `overrides()` is what has been changed since. The agent
281
+ reads this before each model call, so a `/model` lands on the next call rather
282
+ than the next session.
283
+
284
+ Bookkeeping keys are dropped (`META_BOOKKEEPING`) so the result is settings
285
+ only and can go straight to the gateway.
286
+
287
+ Settings chosen before the session had a file count too, even though they were
288
+ folded into the first record rather than appended after it — they are still
289
+ something the user changed, and the alternative is a `/effort` that silently
290
+ applies to nothing because it was chosen one message too early.
291
+ """
292
+ merged: dict[str, Any] = dict(self._chosen)
293
+ for record in self._meta_records()[1:]:
294
+ merged.update(
295
+ {key: value for key, value in record.items() if key not in META_BOOKKEEPING}
296
+ )
297
+ return merged
298
+
299
+ def _meta_records(self) -> builtins.list[dict[str, Any]]:
300
+ return [record for record in self._records if record.get("type") == "meta"]
301
+
302
+ def records(self) -> builtins.list[dict[str, Any]]:
303
+ return list(self._records)
304
+
305
+ def tail(self, count: int = 30) -> builtins.list[dict[str, Any]]:
306
+ """The last `count` raw records — the supervisor's input."""
307
+ return list(self._records[-count:]) if count > 0 else []
308
+
309
+ def messages(self) -> builtins.list[Message]:
310
+ """Fold the transcript into what the gateway sends.
311
+
312
+ Three foldings, each demanded by the wire format:
313
+
314
+ * an `assistant` record plus the `tool_call`s after it become **one** assistant
315
+ message of `ToolUseBlock`s — providers reject a tool call not attached to the
316
+ assistant turn that made it;
317
+ * consecutive `tool_result`s become **one** user message of `ToolResultBlock`s,
318
+ because a turn's parallel calls are answered together;
319
+ * `supervisor` and `inbox` become prefixed user messages — neither is a role any
320
+ provider knows, and the model has to be told who is talking.
321
+ """
322
+ messages: builtins.list[Message] = []
323
+ assistant_blocks: builtins.list[ContentBlock] = []
324
+ result_blocks: builtins.list[ContentBlock] = []
325
+
326
+ def flush_assistant() -> None:
327
+ nonlocal assistant_blocks
328
+ if assistant_blocks:
329
+ messages.append(Message(role="assistant", content=assistant_blocks))
330
+ assistant_blocks = []
331
+
332
+ def flush_results() -> None:
333
+ nonlocal result_blocks
334
+ if result_blocks:
335
+ messages.append(Message(role="user", content=result_blocks))
336
+ result_blocks = []
337
+
338
+ for record in self._records:
339
+ kind = record.get("type")
340
+ if kind not in MODEL_VISIBLE:
341
+ continue
342
+
343
+ if kind == "tool_call":
344
+ flush_results()
345
+ assistant_blocks.append(
346
+ ToolUseBlock(
347
+ id=str(record.get("id", "")),
348
+ name=str(record.get("name", "")),
349
+ input=dict(record.get("arguments") or {}),
350
+ )
351
+ )
352
+ continue
353
+
354
+ if kind == "tool_result":
355
+ flush_assistant()
356
+ result_blocks.append(
357
+ ToolResultBlock(
358
+ tool_use_id=str(record.get("id", "")),
359
+ content=str(record.get("content", "")),
360
+ is_error=bool(record.get("is_error", False)),
361
+ )
362
+ )
363
+ continue
364
+
365
+ flush_assistant()
366
+ flush_results()
367
+ if kind == "assistant":
368
+ text = str(record.get("content", ""))
369
+ if text:
370
+ assistant_blocks.append(TextBlock(text=text))
371
+ continue
372
+ messages.append(Message(role="user", content=_render_user(record)))
373
+
374
+ flush_assistant()
375
+ flush_results()
376
+ return messages
377
+
378
+ # ---- lifecycle ----
379
+
380
+ def close(self) -> None:
381
+ """Close the file if there is one. A deferred session nobody used has none, and
382
+ closing it must leave the directory as it found it."""
383
+ if self._file is not None and not self._file.closed:
384
+ self._file.close()
385
+
386
+ def __enter__(self) -> "Session":
387
+ return self
388
+
389
+ def __exit__(self, *_exc_info: object) -> None:
390
+ self.close()
391
+
392
+ def __repr__(self) -> str:
393
+ return f"Session({self.id}, {len(self._records)} records)"
394
+
395
+
396
+ def _render_user(record: dict[str, Any]) -> str:
397
+ """A `user`, `supervisor` or `inbox` record as text the model can act on.
398
+
399
+ The prefixes matter: a nudge that reads like the user asking for something gets
400
+ treated as a new instruction, while one that names its source gets treated as the
401
+ correction it is. An inbox message carries `refs`, so the paths are the payload.
402
+ """
403
+ kind = record.get("type")
404
+ if kind == "supervisor":
405
+ return f"[supervisor] {record.get('content', '')}"
406
+ if kind == "inbox":
407
+ refs = record.get("refs") or []
408
+ lines = [f"[message from {record.get('from', 'unknown')}] {record.get('subject', '')}"]
409
+ if record.get("body"):
410
+ lines.append(str(record["body"]))
411
+ if refs:
412
+ lines.append("Refs: " + ", ".join(str(ref) for ref in refs))
413
+ return "\n".join(lines)
414
+ return str(record.get("content", ""))
415
+
416
+
417
+ def read_records(path: Path) -> Iterator[dict[str, Any]]:
418
+ """Every well-formed record in a session file.
419
+
420
+ A truncated final line (the process died mid-write) is skipped, not raised on: a
421
+ session you cannot open is one whose trajectory you cannot read, which is exactly
422
+ when you most need to.
423
+ """
424
+ with path.open("r", encoding="utf-8") as handle:
425
+ for line in handle:
426
+ line = line.strip()
427
+ if not line:
428
+ continue
429
+ try:
430
+ record = json.loads(line)
431
+ except json.JSONDecodeError:
432
+ continue
433
+ if isinstance(record, dict):
434
+ yield record
435
+
436
+
437
+ SUMMARY_SCAN = 20
438
+ """How far into a file to look for the first thing that was said. It is the second
439
+ line in every session there has ever been; the bound is there so a corrupt file cannot
440
+ turn a directory listing into a full read."""
441
+
442
+ SUMMARY_CHARS = 72
443
+
444
+
445
+ def _summary(path: Path) -> str:
446
+ """The first thing the user said, as one short line.
447
+
448
+ What makes a session list usable: a column of ULIDs and timestamps does not tell
449
+ you which conversation was which, and the opening message always does.
450
+ """
451
+ for index, record in enumerate(read_records(path)):
452
+ if index >= SUMMARY_SCAN:
453
+ break
454
+ if record.get("type") == "user":
455
+ text = " ".join(str(record.get("content", "")).split())
456
+ return text if len(text) <= SUMMARY_CHARS else text[: SUMMARY_CHARS - 1] + "…"
457
+ return ""
458
+
459
+
460
+ def _first_meta(path: Path) -> dict[str, Any]:
461
+ for record in read_records(path):
462
+ if record.get("type") == "meta":
463
+ return record
464
+ break # meta is always first; anything else means there is none
465
+ return {}
466
+
467
+
468
+ def prune(keep: int = 100, *, directory: str | Path | None = None) -> list[Path]:
469
+ """Delete all but the newest `keep` session files. Returns what was removed."""
470
+ root = sessions_dir(directory)
471
+ if not root.is_dir():
472
+ return []
473
+ stale: Sequence[Path] = sorted(root.glob("*.jsonl"), reverse=True)[keep:]
474
+ removed = []
475
+ for path in stale:
476
+ try:
477
+ path.unlink()
478
+ except OSError:
479
+ continue
480
+ removed.append(path)
481
+ return removed
482
+
483
+
484
+ __all__ = [
485
+ "DEFAULT_SESSION_DIR",
486
+ "META_BOOKKEEPING",
487
+ "MODEL_VISIBLE",
488
+ "Session",
489
+ "new_id",
490
+ "prune",
491
+ "read_records",
492
+ "sessions_dir",
493
+ ]
@@ -0,0 +1,38 @@
1
+ """
2
+ `core/team/` — one agent per container, coordinating through a shared volume.
3
+
4
+ Two files, and the split is the dependency arrow:
5
+
6
+ * `mailbox.py` — a member is a card in `/team/members/`, a message is a JSON file in
7
+ `/team/inbox/<role>/`. Imports nothing from the harness.
8
+ * `tools.py` — `find_teammate` and `send_team_message`, factories closing over one
9
+ role's mailbox. Registered by `Agent.enable_team()`, the same shape `task` uses.
10
+
11
+ Roles are not here: a role is the agent's own `config.toml`, because a new role must be
12
+ a new file rather than a code change.
13
+ """
14
+
15
+ from stcode.core.team.mailbox import (
16
+ ARTIFACTS,
17
+ DEFAULT_TEAM_DIR,
18
+ INBOX,
19
+ KNOWLEDGE,
20
+ MEMBERS,
21
+ Mailbox,
22
+ TeamMember,
23
+ TeamMessage,
24
+ )
25
+ from stcode.core.team.tools import make_find_teammate_tool, make_send_team_message_tool
26
+
27
+ __all__ = [
28
+ "ARTIFACTS",
29
+ "DEFAULT_TEAM_DIR",
30
+ "INBOX",
31
+ "KNOWLEDGE",
32
+ "MEMBERS",
33
+ "Mailbox",
34
+ "TeamMember",
35
+ "TeamMessage",
36
+ "make_find_teammate_tool",
37
+ "make_send_team_message_tool",
38
+ ]