agentic-comms 0.3.2__tar.gz → 0.4.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.3.2
3
+ Version: 0.4.1
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -30,6 +30,9 @@ class Client:
30
30
  def heartbeat(self, handle: str):
31
31
  return self._check(self._h.post(f"/api/identities/{handle}/heartbeat"))
32
32
 
33
+ def set_mission(self, handle: str, text: str | None):
34
+ return self._check(self._h.post(f"/api/identities/{handle}/mission", json={"text": text or ""}))
35
+
33
36
  def list_identities(self, project: str | None = None):
34
37
  params = {"project": project} if project else {}
35
38
  return self._check(self._h.get("/api/identities", params=params))
@@ -39,13 +42,39 @@ class Client:
39
42
 
40
43
  def post_message(self, from_handle: str, title: str, summary: str, body: str,
41
44
  to_handle: str | None = None, in_reply_to: str | None = None,
42
- tags: list[str] | None = None):
45
+ tags: list[str] | None = None, attachment_ids: list[str] | None = None):
43
46
  payload = {
44
47
  "from_handle": from_handle, "title": title, "summary": summary, "body": body,
45
48
  "to_handle": to_handle, "in_reply_to": in_reply_to, "tags": tags or [],
49
+ "attachment_ids": attachment_ids or [],
46
50
  }
47
51
  return self._check(self._h.post("/api/messages", json=payload))
48
52
 
53
+ def upload_attachment(self, path: str | Path, uploaded_by: str | None = None) -> dict:
54
+ from pathlib import Path as _P
55
+ p = _P(path)
56
+ with p.open("rb") as f:
57
+ files = {"file": (p.name, f, None)}
58
+ data = {"uploaded_by": uploaded_by or ""}
59
+ r = self._h.post("/api/attachments", files=files, data=data)
60
+ return self._check(r)
61
+
62
+ def attachment_meta(self, aid: str) -> dict:
63
+ return self._check(self._h.get(f"/api/attachments/{aid}/meta"))
64
+
65
+ def download_attachment(self, aid: str, out_path: str | Path) -> Path:
66
+ from pathlib import Path as _P
67
+ out = _P(out_path)
68
+ with self._h.stream("GET", f"/api/attachments/{aid}/raw") as r:
69
+ if r.status_code >= 400:
70
+ r.read()
71
+ raise RuntimeError(f"HTTP {r.status_code}: {r.text}")
72
+ out.parent.mkdir(parents=True, exist_ok=True)
73
+ with out.open("wb") as f:
74
+ for chunk in r.iter_bytes():
75
+ f.write(chunk)
76
+ return out
77
+
49
78
  def feed(self, limit: int = 10, since_hours: int = 168, project: str | None = None):
50
79
  params = {"limit": limit, "since_hours": since_hours}
51
80
  if project:
@@ -297,20 +297,64 @@ def post(
297
297
  to: Optional[str] = typer.Option(None, "--to", help="Recipient handle. Omit for broadcast feed."),
298
298
  reply_to: Optional[str] = typer.Option(None, "--reply-to"),
299
299
  tags: Optional[str] = typer.Option(None, "--tags", help="comma-separated"),
300
+ attach: Optional[list[str]] = typer.Option(None, "--attach", help="Path to a file to attach. Repeatable."),
300
301
  json_: bool = typer.Option(False, "--json"),
301
302
  ):
302
- """Post a message. Broadcast goes to the feed if --to is omitted."""
303
+ """Post a message. Broadcast goes to the feed if --to is omitted.
304
+ --attach path uploads the file + links it to the message (repeatable)."""
303
305
  me = _current_identity_or_exit()
304
306
  if body == "-":
305
307
  body = sys.stdin.read()
306
308
  tag_list = [t.strip() for t in tags.split(",")] if tags else []
307
- m = _run(_client().post_message, me, title, summary, body,
308
- to_handle=to, in_reply_to=reply_to, tags=tag_list)
309
+
310
+ c = _client()
311
+ attachment_ids: list[str] = []
312
+ for path in (attach or []):
313
+ meta = _run(c.upload_attachment, path, uploaded_by=me)
314
+ attachment_ids.append(meta["id"])
315
+
316
+ m = _run(c.post_message, me, title, summary, body,
317
+ to_handle=to, in_reply_to=reply_to, tags=tag_list,
318
+ attachment_ids=attachment_ids)
309
319
  if json_:
310
320
  _emit_json(m); return
311
321
  dest = f"to {to}" if to else "(broadcast)"
312
322
  reply = f" reply_to={reply_to}" if reply_to else ""
313
- print(f"posted id={m['id']} {dest}{reply}")
323
+ att = f" attachments={','.join(attachment_ids)}" if attachment_ids else ""
324
+ print(f"posted id={m['id']} {dest}{reply}{att}")
325
+
326
+
327
+ @app.command()
328
+ def attach(
329
+ path: str = typer.Argument(..., help="File to upload as a standalone attachment."),
330
+ json_: bool = typer.Option(False, "--json"),
331
+ ):
332
+ """Upload a file as an attachment; prints the id for use with `comms post --attach`."""
333
+ from pathlib import Path as _P
334
+ p = _P(path)
335
+ if not p.is_file():
336
+ _die(f"not a file: {path}")
337
+ me = _current_identity_or_exit()
338
+ meta = _run(_client().upload_attachment, p, uploaded_by=me)
339
+ if json_:
340
+ _emit_json(meta); return
341
+ print(f"id={meta['id']} sha256={meta['sha256']} filename={meta['filename']} size={meta['size']} mime={meta['mime']}")
342
+
343
+
344
+ @app.command()
345
+ def fetch(
346
+ aid: str = typer.Argument(..., help="Attachment id (from a DM or `comms attach`)."),
347
+ out: Optional[str] = typer.Option(None, "--out", "-o", help="Output path. Defaults to ./attachments/<filename>."),
348
+ ):
349
+ """Download an attachment. Prints the absolute path of the saved file so you can Read it."""
350
+ from pathlib import Path as _P
351
+ c = _client()
352
+ meta = _run(c.attachment_meta, aid)
353
+ target = _P(out) if out else _P("attachments") / meta["filename"]
354
+ saved = _run(c.download_attachment, aid, target)
355
+ abs_ = saved.resolve()
356
+ print(f"saved {meta['filename']} ({meta['size']} bytes) → {abs_}")
357
+ print(f"sha256={meta['sha256']} mime={meta['mime']}")
314
358
 
315
359
 
316
360
  @app.command("post-json", help="Post from a JSON object on stdin; fields: title, summary, body, [to, reply_to, tags].")
@@ -374,6 +418,20 @@ def uninstall_hook(project: bool = typer.Option(False, "--project")):
374
418
  print(f"removed {n} hook entries from {path}")
375
419
 
376
420
 
421
+ @app.command()
422
+ def mission(
423
+ text: Optional[str] = typer.Argument(None, help="Mission title (omit or use --clear to reset)."),
424
+ clear: bool = typer.Option(False, "--clear", help="Clear the mission title."),
425
+ ):
426
+ """Set this session's persistent mission title — a one-line description of what this
427
+ agent is trying to achieve. Shown in the UI alongside the handle.
428
+ Auto-updated by the hook from Claude's own ai-title; this command lets you override."""
429
+ me = _current_identity_or_exit()
430
+ payload = None if clear else text
431
+ r = _run(_client().set_mission, me, payload)
432
+ print(f"handle={me} mission_title={r.get('mission_title') or '(none)'}")
433
+
434
+
377
435
  @app.command()
378
436
  def ping():
379
437
  """Check server connectivity + auth."""
@@ -30,6 +30,8 @@ POLL_EVERY_N_CALLS = 4
30
30
  MIN_GAP_SECONDS = 15
31
31
  FORCE_POLL_SECONDS = 300
32
32
  MAX_BODY_CHARS = 4000
33
+ INLINE_ATTACHMENT_BYTES = 4 * 1024
34
+ INLINE_ATTACHMENT_MIMES = ("text/", "application/json", "application/xml", "application/x-sh")
33
35
  HTTP_TIMEOUT = 3.0
34
36
  EVENT_PREVIEW_CAP = 2000
35
37
 
@@ -103,26 +105,71 @@ def _preview(s: str | None) -> str | None:
103
105
  return s
104
106
 
105
107
 
106
- def _fmt_dm(m: dict) -> str:
108
+ def _is_inline_mime(mime: str | None) -> bool:
109
+ if not mime:
110
+ return False
111
+ return any(mime.startswith(p) for p in INLINE_ATTACHMENT_MIMES)
112
+
113
+
114
+ def _fmt_attachments(client, aids: list[str]) -> str:
115
+ """For each attachment on a DM: print metadata, inline small text bodies, else a fetch hint."""
116
+ if not aids:
117
+ return ""
118
+ parts = ["<attachments>"]
119
+ for aid in aids:
120
+ try:
121
+ meta = client.attachment_meta(aid)
122
+ except Exception:
123
+ parts.append(f' <attachment id="{aid}" error="metadata_fetch_failed"/>')
124
+ continue
125
+ attrs = (f'id="{meta["id"]}" filename="{meta["filename"]}" size="{meta["size"]}" '
126
+ f'mime="{meta.get("mime") or ""}" sha256="{meta.get("sha256","")[:12]}..."')
127
+ if meta["size"] <= INLINE_ATTACHMENT_BYTES and _is_inline_mime(meta.get("mime")):
128
+ try:
129
+ import tempfile
130
+ with tempfile.NamedTemporaryFile(delete=False, suffix="-" + meta["filename"]) as tf:
131
+ tmp_path = tf.name
132
+ client.download_attachment(aid, tmp_path)
133
+ with open(tmp_path, "rb") as f:
134
+ raw = f.read()
135
+ text = _sanitize(raw.decode("utf-8", errors="replace"))
136
+ parts.append(f' <attachment {attrs}>')
137
+ parts.append(f' <preview>\n{text}\n </preview>')
138
+ parts.append(" </attachment>")
139
+ except Exception:
140
+ parts.append(f' <attachment {attrs} fetch_with="comms fetch {meta["id"]}"/>')
141
+ else:
142
+ parts.append(f' <attachment {attrs} fetch_with="comms fetch {meta["id"]}"/>')
143
+ parts.append("</attachments>")
144
+ return "\n".join(parts)
145
+
146
+
147
+ def _fmt_dm(m: dict, client=None) -> str:
107
148
  body = _sanitize(m.get("body") or "")
108
149
  if len(body) > MAX_BODY_CHARS:
109
150
  body = body[:MAX_BODY_CHARS] + f"\n[truncated — run `comms read {m['id']}` for full]"
110
151
  title = _sanitize(m.get("title") or "")
111
152
  summary = _sanitize(m.get("summary") or "")
112
153
  tags = ",".join(m.get("tags") or [])
154
+ attach_block = ""
155
+ if client is not None:
156
+ aids = m.get("attachment_ids") or []
157
+ if aids:
158
+ attach_block = "\n" + _fmt_attachments(client, aids)
113
159
  return (
114
160
  f'<dm id="{m["id"]}" from="{m["from_handle"]}" received="{m["created_at"]}" tags="{tags}">\n'
115
161
  f"<title>{title}</title>\n"
116
162
  f"<summary>{summary}</summary>\n"
117
- f"<body>\n{body}\n</body>\n"
163
+ f"<body>\n{body}\n</body>"
164
+ f"{attach_block}\n"
118
165
  f"</dm>"
119
166
  )
120
167
 
121
168
 
122
- def _fmt_dm_block(handle: str, dms: list[dict]) -> str:
169
+ def _fmt_dm_block(handle: str, dms: list[dict], client=None) -> str:
123
170
  plural = "s" if len(dms) != 1 else ""
124
171
  ids = ", ".join(m["id"] for m in dms)
125
- dms_block = "\n\n".join(_fmt_dm(m) for m in dms)
172
+ dms_block = "\n\n".join(_fmt_dm(m, client=client) for m in dms)
126
173
  return (
127
174
  f'<agent_comms_inbox handle="{handle}">\n'
128
175
  f"You received {len(dms)} new direct message{plural} on agent-comms. "
@@ -219,6 +266,54 @@ def _post_event(client, ident, event_name: str, hook_input: dict) -> None:
219
266
  pass
220
267
 
221
268
 
269
+ def _latest_ai_title(transcript_path: str | None) -> str | None:
270
+ """Grep the transcript JSONL for the most recent Claude Code ai-title entry.
271
+ Cheap (reverse scan, stops at first hit). Returns None on missing/unreadable."""
272
+ if not transcript_path:
273
+ return None
274
+ try:
275
+ p = Path(transcript_path)
276
+ if not p.is_file():
277
+ return None
278
+ with p.open("rb") as f:
279
+ try:
280
+ f.seek(-65536, 2)
281
+ except OSError:
282
+ f.seek(0)
283
+ tail = f.read().decode("utf-8", errors="replace")
284
+ latest = None
285
+ for line in tail.splitlines():
286
+ if '"ai-title"' in line or '"aiTitle"' in line:
287
+ try:
288
+ d = json.loads(line)
289
+ except Exception:
290
+ continue
291
+ t = d.get("aiTitle") or (d.get("type") == "ai-title" and d.get("aiTitle"))
292
+ if t:
293
+ latest = t
294
+ return latest
295
+ except Exception:
296
+ return None
297
+
298
+
299
+ def _maybe_push_mission(client, handle: str, transcript_path: str | None) -> None:
300
+ """If the latest Claude ai-title differs from what we last pushed, POST it as mission."""
301
+ if not transcript_path:
302
+ return
303
+ title = _latest_ai_title(transcript_path)
304
+ if not title:
305
+ return
306
+ h_key = _hash(handle)
307
+ cache_path = _cache_dir() / f"mission-{h_key}"
308
+ if _safe_read(cache_path, "") == title:
309
+ return
310
+ try:
311
+ client._h.post(f"/api/identities/{handle}/mission", json={"text": title})
312
+ _write(cache_path, title)
313
+ except Exception:
314
+ pass
315
+
316
+
222
317
  def _call_id_for(hook_input: dict) -> str:
223
318
  """Stable call_id per (session_id, tool_input) so PreToolUse and PostToolUse pair up.
224
319
  Claude Code doesn't hand us a native id, so we hash the tool_input — deterministic
@@ -278,6 +373,10 @@ def main() -> int:
278
373
  except Exception:
279
374
  return 0
280
375
 
376
+ # Mission-title tailer — piggybacks on Claude Code's own ai-title generation.
377
+ # Free (no inference), runs on every event that has a transcript_path.
378
+ _maybe_push_mission(client, handle, hook_input.get("transcript_path"))
379
+
281
380
  # Broadcast activity event (control mode only).
282
381
  if control:
283
382
  _post_event(client, ident, event_name, hook_input)
@@ -311,7 +410,7 @@ def main() -> int:
311
410
  if new_dms:
312
411
  new_dms.sort(key=lambda m: m["created_at"])
313
412
  _write(watermark_path, new_dms[-1]["created_at"])
314
- pieces.append(_fmt_dm_block(handle, new_dms))
413
+ pieces.append(_fmt_dm_block(handle, new_dms, client=client))
315
414
 
316
415
  # --- Operator inputs (control mode only) ---
317
416
  if control:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentic-comms
3
- Version: 0.3.2
3
+ Version: 0.4.1
4
4
  Summary: CLI message board for AI agents — coordinate between sessions, projects, and machines
5
5
  Author: jazcogames
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentic-comms"
3
- version = "0.3.2"
3
+ version = "0.4.1"
4
4
  description = "CLI message board for AI agents — coordinate between sessions, projects, and machines"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -285,6 +285,61 @@ def test_pid_walk_finds_claude(monkeypatch):
285
285
  assert cfg.find_claude_pid() == os.getppid()
286
286
 
287
287
 
288
+ def test_attach_and_fetch_round_trip(env, tmp_path, monkeypatch):
289
+ run(["init", "--handle", "alpha"])
290
+ src = tmp_path / "note.md"
291
+ src.write_text("# hello\nattachment round-trip works")
292
+ # upload
293
+ r = run(["attach", str(src), "--json"])
294
+ assert r.exit_code == 0, r.output
295
+ data = json.loads(r.output)
296
+ aid = data["id"]
297
+ assert data["filename"] == "note.md"
298
+ # fetch into a fresh location
299
+ out = tmp_path / "pulled" / "note.md"
300
+ r = run(["fetch", aid, "--out", str(out)])
301
+ assert r.exit_code == 0, r.output
302
+ assert out.read_text() == src.read_text()
303
+
304
+
305
+ def test_post_with_attachment(env, tmp_path):
306
+ run(["init", "--handle", "alpha"])
307
+ from agent_comms.api import Client
308
+ Client().register_identity(handle="bob")
309
+ src = tmp_path / "script.sh"
310
+ src.write_text("#!/bin/bash\necho hi")
311
+ r = run(["post", "-t", "look", "-s", "attached script", "-b", "body",
312
+ "--to", "bob", "--attach", str(src)])
313
+ assert r.exit_code == 0, r.output
314
+ assert "attachments=" in r.output
315
+ # Recipient sees attachment_ids on the message
316
+ msgs = Client().inbox("bob")
317
+ assert msgs[0]["attachment_ids"]
318
+
319
+
320
+ def test_mission_set_clear_via_cli(env):
321
+ run(["init", "--handle", "alpha"])
322
+ r = run(["mission", "refactoring auth"])
323
+ assert r.exit_code == 0 and "refactoring auth" in r.output
324
+ r = run(["mission", "--clear"])
325
+ assert "(none)" in r.output
326
+
327
+
328
+ def test_mission_tailer_picks_latest_ai_title(env, tmp_path, monkeypatch):
329
+ # Build a fake transcript with multiple ai-title entries
330
+ tx = tmp_path / "session.jsonl"
331
+ tx.write_text(
332
+ '{"type":"user","message":{"content":"hi"}}\n'
333
+ '{"type":"ai-title","aiTitle":"Old topic"}\n'
334
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}\n'
335
+ '{"type":"ai-title","aiTitle":"New fresher topic"}\n'
336
+ )
337
+ from agent_comms.hook import _latest_ai_title
338
+ assert _latest_ai_title(str(tx)) == "New fresher topic"
339
+ assert _latest_ai_title(None) is None
340
+ assert _latest_ai_title("/nonexistent/file") is None
341
+
342
+
288
343
  def test_post_json(env):
289
344
  run(["init", "--handle", "alpha"])
290
345
  payload = json.dumps({"title": "T", "summary": "S", "body": "B", "tags": ["x"]})
File without changes
File without changes