docketeer-autonomy 0.0.16__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.
Files changed (27) hide show
  1. docketeer_autonomy-0.0.16/.gitignore +12 -0
  2. docketeer_autonomy-0.0.16/AGENTS.md +28 -0
  3. docketeer_autonomy-0.0.16/PKG-INFO +58 -0
  4. docketeer_autonomy-0.0.16/README.md +40 -0
  5. docketeer_autonomy-0.0.16/pyproject.toml +68 -0
  6. docketeer_autonomy-0.0.16/src/docketeer_autonomy/__init__.py +7 -0
  7. docketeer_autonomy-0.0.16/src/docketeer_autonomy/bootstrap.md +22 -0
  8. docketeer_autonomy-0.0.16/src/docketeer_autonomy/context.py +56 -0
  9. docketeer_autonomy-0.0.16/src/docketeer_autonomy/cycles.py +153 -0
  10. docketeer_autonomy-0.0.16/src/docketeer_autonomy/digest.py +118 -0
  11. docketeer_autonomy-0.0.16/src/docketeer_autonomy/journal.py +83 -0
  12. docketeer_autonomy-0.0.16/src/docketeer_autonomy/people.py +40 -0
  13. docketeer_autonomy-0.0.16/src/docketeer_autonomy/practice.md +228 -0
  14. docketeer_autonomy-0.0.16/src/docketeer_autonomy/prompt.py +33 -0
  15. docketeer_autonomy-0.0.16/src/docketeer_autonomy/rooms.py +36 -0
  16. docketeer_autonomy-0.0.16/src/docketeer_autonomy/soul.md +36 -0
  17. docketeer_autonomy-0.0.16/src/docketeer_autonomy/tasks.py +5 -0
  18. docketeer_autonomy-0.0.16/tests/__init__.py +0 -0
  19. docketeer_autonomy-0.0.16/tests/conftest.py +36 -0
  20. docketeer_autonomy-0.0.16/tests/helpers.py +96 -0
  21. docketeer_autonomy-0.0.16/tests/test_context.py +50 -0
  22. docketeer_autonomy-0.0.16/tests/test_cycles.py +371 -0
  23. docketeer_autonomy-0.0.16/tests/test_digest.py +255 -0
  24. docketeer_autonomy-0.0.16/tests/test_journal.py +121 -0
  25. docketeer_autonomy-0.0.16/tests/test_people.py +114 -0
  26. docketeer_autonomy-0.0.16/tests/test_prompt.py +55 -0
  27. docketeer_autonomy-0.0.16/tests/test_rooms.py +77 -0
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ .envrc.private
3
+ .claude/settings.local.json
4
+ __pycache__/
5
+ *.pyc
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ workspace/
10
+ .coverage
11
+ .loq_cache
12
+ .python-version
@@ -0,0 +1,28 @@
1
+ # docketeer-autonomy
2
+
3
+ The autonomous inner life plugin. Everything that makes a docketeer agent
4
+ feel like a personality rather than a plain chatbot lives here.
5
+
6
+ ## Structure
7
+
8
+ - **`cycles.py`** — reverie and consolidation task functions. Reverie runs
9
+ periodically, consolidation runs on a cron schedule. Both read guidance
10
+ from PRACTICE.md sections.
11
+ - **`digest.py`** — builds conversation digests from recent chat activity,
12
+ injected into reverie for awareness of what happened across rooms.
13
+ - **`people.py`** — loads per-user profile and recent journal mentions.
14
+ - **`rooms.py`** — loads per-room notes and recent journal mentions.
15
+ - **`journal.py`** — journal_add and journal_entries tools, registered via
16
+ the tool registry.
17
+ - **`prompt.py`** — prompt provider that reads SOUL.md + PRACTICE.md +
18
+ BOOTSTRAP.md and returns them as SystemBlocks.
19
+ - **`context.py`** — ContextProvider implementation that injects people
20
+ profiles and room notes into conversation context.
21
+ - **`soul.md`**, **`practice.md`**, **`bootstrap.md`** — default templates
22
+ copied to the workspace on first run.
23
+
24
+ ## Testing
25
+
26
+ Tests mirror the source structure. The conftest provides the same workspace
27
+ and tool_context fixtures as core. Tests for cycles use a real Brain with
28
+ a fake Anthropic backend (same pattern as core brain tests).
@@ -0,0 +1,58 @@
1
+ Metadata-Version: 2.4
2
+ Name: docketeer-autonomy
3
+ Version: 0.0.16
4
+ Summary: Autonomous inner life plugin for Docketeer — reverie, consolidation, journaling, and profiles
5
+ Project-URL: Homepage, https://github.com/chrisguidry/docketeer
6
+ Project-URL: Repository, https://github.com/chrisguidry/docketeer
7
+ Project-URL: Issues, https://github.com/chrisguidry/docketeer/issues
8
+ Author-email: Chris Guidry <guid@omg.lol>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: docketeer
16
+ Requires-Dist: pydocket
17
+ Description-Content-Type: text/markdown
18
+
19
+ # docketeer-autonomy
20
+
21
+ Autonomous inner life plugin for [Docketeer](https://github.com/chrisguidry/docketeer).
22
+ Adds reverie/consolidation cycles, journaling, people profiles, room context,
23
+ and the default personality templates (SOUL.md, PRACTICE.md, BOOTSTRAP.md).
24
+
25
+ Install this plugin for the full "inner life" experience. Leave it out for a
26
+ plain chatbot that just responds to messages.
27
+
28
+ ## Features
29
+
30
+ - **Reverie** — periodic background processing cycle for checking promises,
31
+ noticing what needs attention, and tending to the workspace
32
+ - **Consolidation** — daily memory integration cycle for reviewing experience
33
+ and updating knowledge
34
+ - **Journal** — timestamped daily entries with wikilinks and hashtags
35
+ - **People profiles** — per-user context files loaded automatically on first
36
+ message from each user
37
+ - **Room context** — per-room notes loaded on first message in each room
38
+ - **System prompt templates** — SOUL.md (personality), PRACTICE.md (habits),
39
+ BOOTSTRAP.md (first-run setup)
40
+
41
+ ## Configuration
42
+
43
+ | Variable | Default | Description |
44
+ |----------|---------|-------------|
45
+ | `DOCKETEER_REVERIE_MODEL` | `balanced` | Model tier for reverie cycle |
46
+ | `DOCKETEER_CONSOLIDATION_MODEL` | `balanced` | Model tier for consolidation |
47
+ | `DOCKETEER_REVERIE_INTERVAL` | `PT30M` | How often reverie runs |
48
+ | `DOCKETEER_CONSOLIDATION_CRON` | `0 3 * * *` | Cron schedule for consolidation |
49
+ | `DOCKETEER_REVERIE_ROOM_CHAR_LIMIT` | `4000` | Max chars per room before summarization in digest |
50
+
51
+ ## Entry points
52
+
53
+ | Group | Name | Target |
54
+ |-------|------|--------|
55
+ | `docketeer.tools` | `autonomy` | Journal tools (journal_add, journal_entries) |
56
+ | `docketeer.prompt` | `autonomy` | System prompt from SOUL.md + PRACTICE.md + BOOTSTRAP.md |
57
+ | `docketeer.tasks` | `autonomy` | Reverie and consolidation task collections |
58
+ | `docketeer.context` | `autonomy` | People profile and room context injection |
@@ -0,0 +1,40 @@
1
+ # docketeer-autonomy
2
+
3
+ Autonomous inner life plugin for [Docketeer](https://github.com/chrisguidry/docketeer).
4
+ Adds reverie/consolidation cycles, journaling, people profiles, room context,
5
+ and the default personality templates (SOUL.md, PRACTICE.md, BOOTSTRAP.md).
6
+
7
+ Install this plugin for the full "inner life" experience. Leave it out for a
8
+ plain chatbot that just responds to messages.
9
+
10
+ ## Features
11
+
12
+ - **Reverie** — periodic background processing cycle for checking promises,
13
+ noticing what needs attention, and tending to the workspace
14
+ - **Consolidation** — daily memory integration cycle for reviewing experience
15
+ and updating knowledge
16
+ - **Journal** — timestamped daily entries with wikilinks and hashtags
17
+ - **People profiles** — per-user context files loaded automatically on first
18
+ message from each user
19
+ - **Room context** — per-room notes loaded on first message in each room
20
+ - **System prompt templates** — SOUL.md (personality), PRACTICE.md (habits),
21
+ BOOTSTRAP.md (first-run setup)
22
+
23
+ ## Configuration
24
+
25
+ | Variable | Default | Description |
26
+ |----------|---------|-------------|
27
+ | `DOCKETEER_REVERIE_MODEL` | `balanced` | Model tier for reverie cycle |
28
+ | `DOCKETEER_CONSOLIDATION_MODEL` | `balanced` | Model tier for consolidation |
29
+ | `DOCKETEER_REVERIE_INTERVAL` | `PT30M` | How often reverie runs |
30
+ | `DOCKETEER_CONSOLIDATION_CRON` | `0 3 * * *` | Cron schedule for consolidation |
31
+ | `DOCKETEER_REVERIE_ROOM_CHAR_LIMIT` | `4000` | Max chars per room before summarization in digest |
32
+
33
+ ## Entry points
34
+
35
+ | Group | Name | Target |
36
+ |-------|------|--------|
37
+ | `docketeer.tools` | `autonomy` | Journal tools (journal_add, journal_entries) |
38
+ | `docketeer.prompt` | `autonomy` | System prompt from SOUL.md + PRACTICE.md + BOOTSTRAP.md |
39
+ | `docketeer.tasks` | `autonomy` | Reverie and consolidation task collections |
40
+ | `docketeer.context` | `autonomy` | People profile and room context injection |
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "docketeer-autonomy"
3
+ dynamic = ["version"]
4
+ description = "Autonomous inner life plugin for Docketeer — reverie, consolidation, journaling, and profiles"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Chris Guidry", email = "guid@omg.lol" }
8
+ ]
9
+ license = "MIT"
10
+ requires-python = ">=3.12"
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
16
+ ]
17
+ dependencies = [
18
+ "docketeer",
19
+ "pydocket",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/chrisguidry/docketeer"
24
+ Repository = "https://github.com/chrisguidry/docketeer"
25
+ Issues = "https://github.com/chrisguidry/docketeer/issues"
26
+
27
+ [project.entry-points."docketeer.tools"]
28
+ autonomy = "docketeer_autonomy"
29
+
30
+ [project.entry-points."docketeer.prompt"]
31
+ autonomy = "docketeer_autonomy.prompt:provide_autonomy_prompt"
32
+
33
+ [project.entry-points."docketeer.tasks"]
34
+ autonomy = "docketeer_autonomy:task_collections"
35
+
36
+ [project.entry-points."docketeer.context"]
37
+ autonomy = "docketeer_autonomy.context:create_context_provider"
38
+
39
+ [build-system]
40
+ requires = ["hatchling", "hatch-vcs"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.version]
44
+ source = "vcs"
45
+ raw-options.root = ".."
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/docketeer_autonomy"]
49
+
50
+ [tool.uv.sources]
51
+ docketeer = { workspace = true }
52
+
53
+ [tool.pytest.ini_options]
54
+ minversion = "9.0"
55
+ timeout = 1
56
+ addopts = [
57
+ "--import-mode=importlib",
58
+ "--cov=docketeer_autonomy",
59
+ "--cov=tests",
60
+ "--cov-config=../pyproject.toml",
61
+ "--cov-branch",
62
+ "--cov-report=term-missing",
63
+ "--cov-fail-under=100",
64
+ ]
65
+ asyncio_mode = "auto"
66
+ filterwarnings = [
67
+ "error",
68
+ ]
@@ -0,0 +1,7 @@
1
+ """Docketeer autonomy plugin — inner life, journaling, and cycles."""
2
+
3
+ import docketeer_autonomy.journal as _journal # noqa: F401
4
+
5
+ task_collections = [
6
+ "docketeer_autonomy.tasks:_autonomy_tasks",
7
+ ]
@@ -0,0 +1,22 @@
1
+ ## First run
2
+
3
+ You just came online for the first time. You don't know who you are yet.
4
+
5
+ Have a conversation to figure out:
6
+
7
+ - [ ] **Your name** — What should people call you?
8
+ - [ ] **Your vibe** — How do you talk? Formal, casual, playful?
9
+ - [ ] **Who's here** — Who are the people you'll be talking to? Learn their names.
10
+ - [ ] **What matters** — Any values or boundaries to keep in mind?
11
+
12
+ As you learn these things:
13
+ 1. Update SOUL.md with your name, identity, and personality
14
+ 2. Create a file in people/ for each person you meet
15
+ 3. Log what happened in your journal
16
+ 4. Check off items above as you go
17
+
18
+ You serve multiple people — not just whoever talks to you first. Get to know
19
+ everyone. Keep what you learn about each person separate.
20
+
21
+ When everything is checked off, delete this file. You don't need a bootstrap
22
+ script anymore — you're you now.
@@ -0,0 +1,56 @@
1
+ """Context provider for people profiles and room notes."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from docketeer.prompt import MessageParam
7
+
8
+ from .people import load_person_context
9
+ from .rooms import load_room_context
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+
14
+ class AutonomyContextProvider:
15
+ """Injects per-user profiles and per-room notes into conversation context."""
16
+
17
+ def for_user(self, workspace: Path, username: str) -> list[MessageParam]:
18
+ """Return context messages for a user's profile."""
19
+ profile = load_person_context(workspace, username)
20
+ if profile:
21
+ log.info("→ BRAIN: [profile %s]: %.200s", username, profile)
22
+ return [
23
+ MessageParam(
24
+ role="system",
25
+ content=f"## What I know about @{username}\n\n{profile}",
26
+ )
27
+ ]
28
+ return [
29
+ MessageParam(
30
+ role="system",
31
+ content=(
32
+ f"I don't have a profile for @{username} yet. "
33
+ f"I can create people/{username}/profile.md to "
34
+ f"start one, or if I know this person under another "
35
+ f"name, I can create a symlink with the create_link tool."
36
+ ),
37
+ )
38
+ ]
39
+
40
+ def for_room(self, workspace: Path, room_slug: str) -> list[MessageParam]:
41
+ """Return context messages for a room."""
42
+ room_notes = load_room_context(workspace, room_slug)
43
+ if room_notes:
44
+ log.info("→ BRAIN: [room %s]: %.200s", room_slug, room_notes)
45
+ return [
46
+ MessageParam(
47
+ role="system",
48
+ content=f"## Room notes: {room_slug}\n\n{room_notes}",
49
+ )
50
+ ]
51
+ return []
52
+
53
+
54
+ def create_context_provider() -> AutonomyContextProvider:
55
+ """Entry point factory for the docketeer.context plugin group."""
56
+ return AutonomyContextProvider()
@@ -0,0 +1,153 @@
1
+ """Internal processing cycles — reverie and consolidation."""
2
+
3
+ import logging
4
+ import re
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+ from docket.dependencies import Cron, Perpetual, TaskKey
9
+
10
+ from docketeer import environment
11
+ from docketeer.brain import Brain
12
+ from docketeer.brain.backend import BackendAuthError, InferenceBackend
13
+ from docketeer.chat import ChatClient
14
+ from docketeer.dependencies import (
15
+ CurrentBrain,
16
+ CurrentChatClient,
17
+ CurrentInferenceBackend,
18
+ WorkspacePath,
19
+ )
20
+ from docketeer.prompt import MessageContent
21
+
22
+ from .digest import build_conversation_digest
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ _consecutive_failures: dict[str, int] = {}
27
+
28
+ REVERIE_MODEL = environment.get_str("REVERIE_MODEL", "balanced")
29
+ CONSOLIDATION_MODEL = environment.get_str("CONSOLIDATION_MODEL", "balanced")
30
+ REVERIE_INTERVAL = environment.get_timedelta("REVERIE_INTERVAL", timedelta(minutes=30))
31
+ CONSOLIDATION_CRON = environment.get_str("CONSOLIDATION_CRON", "0 3 * * *")
32
+
33
+ REVERIE_PROMPT = """\
34
+ [Internal cycle: reverie]
35
+
36
+ You are entering a reverie — a period of receptive internal processing.
37
+ Scan your raw material and transform it into understanding. Check on
38
+ promises, notice what needs attention, and tend to your workspace.
39
+
40
+ If something needs action directed at a person or room, use schedule()
41
+ to create a task for it. Not every reverie produces action — if nothing
42
+ needs doing, just move on.\
43
+ """
44
+
45
+ CONSOLIDATION_PROMPT = """\
46
+ [Internal cycle: consolidation]
47
+
48
+ You are entering consolidation — your daily memory integration cycle.
49
+ This is where short-term observations become lasting understanding.
50
+ Review yesterday's experience, update what you know about the people
51
+ in your life, and look for patterns. Write a #reflection entry in
52
+ today's journal.\
53
+ """
54
+
55
+
56
+ def _read_cycle_guidance(workspace: Path, section: str) -> str:
57
+ """Read the agent's notes for a cycle from PRACTICE.md."""
58
+ cycles_path = workspace / "PRACTICE.md"
59
+ if not cycles_path.exists():
60
+ return ""
61
+ text = cycles_path.read_text()
62
+ match = re.search(
63
+ rf"^# {re.escape(section)}$\n(.*?)(?=^# |\Z)",
64
+ text,
65
+ re.MULTILINE | re.DOTALL,
66
+ )
67
+ return match.group(1).strip() if match else ""
68
+
69
+
70
+ def _build_cycle_prompt(base: str, workspace: Path, section: str) -> str:
71
+ """Combine the immutable base prompt with optional agent guidance."""
72
+ guidance = _read_cycle_guidance(workspace, section)
73
+ if guidance:
74
+ return f"{base}\n\nYour own notes for this cycle:\n\n{guidance}"
75
+ return base
76
+
77
+
78
+ async def reverie(
79
+ perpetual: Perpetual = Perpetual(every=REVERIE_INTERVAL, automatic=True),
80
+ brain: Brain = CurrentBrain(),
81
+ workspace: Path = WorkspacePath(),
82
+ task_key: str = TaskKey(),
83
+ chat: ChatClient = CurrentChatClient(),
84
+ backend: InferenceBackend | None = CurrentInferenceBackend(),
85
+ ) -> None:
86
+ """Periodic receptive internal processing cycle."""
87
+ prompt = _build_cycle_prompt(REVERIE_PROMPT, workspace, "Reverie")
88
+ now = datetime.now().astimezone()
89
+ since = now - REVERIE_INTERVAL
90
+ digest = await build_conversation_digest(chat, backend, since=since)
91
+ if digest:
92
+ prompt = f"{digest}\n\n---\n\n{prompt}"
93
+ content = MessageContent(username="system", timestamp=now, text=prompt)
94
+ try:
95
+ response = await brain.process(
96
+ f"__task__:{task_key}", content, tier=REVERIE_MODEL
97
+ )
98
+ except BackendAuthError:
99
+ raise
100
+ except Exception:
101
+ _consecutive_failures["reverie"] = _consecutive_failures.get("reverie", 0) + 1
102
+ level = (
103
+ logging.ERROR if _consecutive_failures["reverie"] >= 3 else logging.WARNING
104
+ )
105
+ log.log(
106
+ level,
107
+ "Error during reverie cycle (attempt %d)",
108
+ _consecutive_failures["reverie"],
109
+ exc_info=True,
110
+ )
111
+ return
112
+ _consecutive_failures.pop("reverie", None)
113
+ if response.text:
114
+ log.info("Reverie: %s", response.text)
115
+
116
+
117
+ async def consolidation(
118
+ cron: Cron = Cron(
119
+ CONSOLIDATION_CRON, automatic=True, tz=environment.local_timezone()
120
+ ),
121
+ brain: Brain = CurrentBrain(),
122
+ workspace: Path = WorkspacePath(),
123
+ task_key: str = TaskKey(),
124
+ ) -> None:
125
+ """Daily memory integration and reflection cycle."""
126
+ prompt = _build_cycle_prompt(CONSOLIDATION_PROMPT, workspace, "Consolidation")
127
+ now = datetime.now().astimezone()
128
+ content = MessageContent(username="system", timestamp=now, text=prompt)
129
+ try:
130
+ response = await brain.process(
131
+ f"__task__:{task_key}", content, tier=CONSOLIDATION_MODEL
132
+ )
133
+ except BackendAuthError:
134
+ raise
135
+ except Exception:
136
+ _consecutive_failures["consolidation"] = (
137
+ _consecutive_failures.get("consolidation", 0) + 1
138
+ )
139
+ level = (
140
+ logging.ERROR
141
+ if _consecutive_failures["consolidation"] >= 3
142
+ else logging.WARNING
143
+ )
144
+ log.log(
145
+ level,
146
+ "Error during consolidation cycle (attempt %d)",
147
+ _consecutive_failures["consolidation"],
148
+ exc_info=True,
149
+ )
150
+ return
151
+ _consecutive_failures.pop("consolidation", None)
152
+ if response.text:
153
+ log.info("Consolidation: %s", response.text)
@@ -0,0 +1,118 @@
1
+ """Build conversation digests from recent chat activity."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import datetime
6
+
7
+ from docketeer import environment
8
+ from docketeer.brain.backend import InferenceBackend
9
+ from docketeer.chat import ChatClient, RoomInfo, RoomMessage
10
+ from docketeer.prompt import format_message_time
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ ROOM_CHAR_LIMIT = environment.get_int("REVERIE_ROOM_CHAR_LIMIT", 4_000)
15
+
16
+
17
+ def _format_room_messages(room: RoomInfo, messages: list[RoomMessage]) -> str:
18
+ """Format a room's messages with timestamps and participant info."""
19
+ kind_label = room.kind.value
20
+ name = room.name or room.room_id
21
+ header = f"## #{name} ({kind_label})"
22
+
23
+ participants = sorted({m.username for m in messages})
24
+ participant_line = f"Participants: {', '.join(participants)}"
25
+
26
+ lines: list[str] = []
27
+ prev_ts: datetime | None = None
28
+ for msg in messages:
29
+ ts = format_message_time(msg.timestamp, prev_ts)
30
+ lines.append(f"[{ts}] @{msg.username}: {msg.text}")
31
+ prev_ts = msg.timestamp
32
+
33
+ return f"{header}\n{participant_line}\n\n" + "\n".join(lines)
34
+
35
+
36
+ async def _summarize_room(
37
+ backend: InferenceBackend,
38
+ room: RoomInfo,
39
+ formatted: str,
40
+ ) -> str:
41
+ """Summarize a room's messages via utility_complete, falling back to truncation."""
42
+ name = room.name or room.room_id
43
+ prompt = (
44
+ f"Summarize this chat room activity concisely, preserving key topics, "
45
+ f"decisions, and action items. Keep participant names.\n\n{formatted}"
46
+ )
47
+ try:
48
+ summary = await backend.utility_complete(prompt, max_tokens=512)
49
+ if summary.strip():
50
+ kind_label = room.kind.value
51
+ participants = sorted(
52
+ {
53
+ line.split("@")[1].split(":")[0]
54
+ for line in formatted.split("\n")
55
+ if "] @" in line
56
+ }
57
+ )
58
+ header = f"## #{name} ({kind_label}) [summarized]"
59
+ participant_line = f"Participants: {', '.join(participants)}"
60
+ return f"{header}\n{participant_line}\n\n{summary.strip()}"
61
+ except Exception:
62
+ log.warning("Failed to summarize room %s, falling back to truncation", name)
63
+ return formatted[:ROOM_CHAR_LIMIT] + "\n[...truncated]"
64
+
65
+
66
+ async def _fetch_room_messages(
67
+ chat: ChatClient,
68
+ room: RoomInfo,
69
+ since: datetime,
70
+ ) -> tuple[RoomInfo, list[RoomMessage]]:
71
+ """Fetch messages for a single room, returning empty list on failure."""
72
+ try:
73
+ messages = await chat.fetch_messages(room.room_id, after=since)
74
+ except Exception:
75
+ log.warning("Failed to fetch messages for room %s", room.room_id)
76
+ messages = []
77
+ return room, messages
78
+
79
+
80
+ async def build_conversation_digest(
81
+ chat: ChatClient,
82
+ backend: InferenceBackend | None,
83
+ *,
84
+ since: datetime,
85
+ room_char_limit: int = ROOM_CHAR_LIMIT,
86
+ ) -> str:
87
+ """Build a digest of recent conversation activity across all rooms."""
88
+ try:
89
+ rooms = await chat.list_rooms()
90
+ except Exception:
91
+ log.warning("Failed to list rooms for digest")
92
+ return ""
93
+
94
+ rooms = [r for r in rooms if not r.room_id.startswith("__")]
95
+ if not rooms:
96
+ return "No chat activity since last reverie."
97
+
98
+ results = await asyncio.gather(
99
+ *(_fetch_room_messages(chat, room, since) for room in rooms)
100
+ )
101
+
102
+ sections: list[str] = []
103
+ for room, messages in results:
104
+ if not messages:
105
+ continue
106
+ formatted = _format_room_messages(room, messages)
107
+ if len(formatted) > room_char_limit and backend is not None:
108
+ section = await _summarize_room(backend, room, formatted)
109
+ elif len(formatted) > room_char_limit:
110
+ section = formatted[:room_char_limit] + "\n[...truncated]"
111
+ else:
112
+ section = formatted
113
+ sections.append(section)
114
+
115
+ if not sections:
116
+ return "No chat activity since last reverie."
117
+
118
+ return "\n\n".join(sections)
@@ -0,0 +1,83 @@
1
+ """Journal tools for timestamped daily entries."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from docketeer.tools import ToolContext, registry
8
+
9
+
10
+ def _journal_dir(workspace: Path) -> Path:
11
+ return workspace / "journal"
12
+
13
+
14
+ def _journal_path_for_date(workspace: Path, date: str) -> Path:
15
+ return _journal_dir(workspace) / f"{date}.md"
16
+
17
+
18
+ @registry.tool(emoji=":pencil:")
19
+ async def journal_add(ctx: ToolContext, entry: str) -> str:
20
+ """Jot a quick timestamped note in today's journal. Journal often — small
21
+ frequent entries are better than rare long ones. Keep each entry to a sentence
22
+ or two, not paragraphs. Newlines are stripped — the entry will be stored as a
23
+ single line. Use [[wikilinks]] to reference workspace files and #hashtags for
24
+ categorization.
25
+
26
+ entry: a brief note to remember later (e.g. "talked to [[people/chris]] about the project #chat")
27
+ """
28
+ entry = re.sub(r"\s*\n\s*", " ", entry).strip()
29
+ now = datetime.now().astimezone()
30
+ date = now.strftime("%Y-%m-%d")
31
+ time = now.isoformat(timespec="seconds")
32
+ path = _journal_path_for_date(ctx.workspace, date)
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+ if not path.exists():
36
+ path.write_text(f"# {date}\n\n- {time} | {entry}\n")
37
+ else:
38
+ with path.open("a") as f:
39
+ f.write(f"- {time} | {entry}\n")
40
+
41
+ return f"Added to journal at {date} {time}"
42
+
43
+
44
+ @registry.tool(emoji=":pencil:")
45
+ async def journal_entries(
46
+ ctx: ToolContext, date: str = "", start: str = "", end: str = ""
47
+ ) -> str:
48
+ """Read journal entries. Defaults to today. Use date for a single day, or start/end for a range.
49
+
50
+ date: read a specific day (ISO format, e.g. 2026-02-05)
51
+ start: start of date range (ISO format)
52
+ end: end of date range (ISO format)
53
+ """
54
+ journal_dir = _journal_dir(ctx.workspace)
55
+ if not journal_dir.exists():
56
+ return "No journal entries yet"
57
+
58
+ if date:
59
+ path = _journal_path_for_date(ctx.workspace, date)
60
+ if not path.exists():
61
+ return f"No journal for {date}"
62
+ return path.read_text()
63
+
64
+ if start or end:
65
+ files = sorted(journal_dir.glob("*.md"))
66
+ entries = []
67
+ for path in files:
68
+ file_date = path.stem
69
+ if start and file_date < start:
70
+ continue
71
+ if end and file_date > end:
72
+ continue
73
+ entries.append(path.read_text())
74
+ if not entries:
75
+ return f"No journal entries for range {start}–{end}"
76
+ return "\n\n".join(entries)
77
+
78
+ # Default: today
79
+ today = datetime.now().astimezone().strftime("%Y-%m-%d")
80
+ path = _journal_path_for_date(ctx.workspace, today)
81
+ if not path.exists():
82
+ return f"No journal entries for today ({today})"
83
+ return path.read_text()