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.
- docketeer_autonomy-0.0.16/.gitignore +12 -0
- docketeer_autonomy-0.0.16/AGENTS.md +28 -0
- docketeer_autonomy-0.0.16/PKG-INFO +58 -0
- docketeer_autonomy-0.0.16/README.md +40 -0
- docketeer_autonomy-0.0.16/pyproject.toml +68 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/__init__.py +7 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/bootstrap.md +22 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/context.py +56 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/cycles.py +153 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/digest.py +118 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/journal.py +83 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/people.py +40 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/practice.md +228 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/prompt.py +33 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/rooms.py +36 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/soul.md +36 -0
- docketeer_autonomy-0.0.16/src/docketeer_autonomy/tasks.py +5 -0
- docketeer_autonomy-0.0.16/tests/__init__.py +0 -0
- docketeer_autonomy-0.0.16/tests/conftest.py +36 -0
- docketeer_autonomy-0.0.16/tests/helpers.py +96 -0
- docketeer_autonomy-0.0.16/tests/test_context.py +50 -0
- docketeer_autonomy-0.0.16/tests/test_cycles.py +371 -0
- docketeer_autonomy-0.0.16/tests/test_digest.py +255 -0
- docketeer_autonomy-0.0.16/tests/test_journal.py +121 -0
- docketeer_autonomy-0.0.16/tests/test_people.py +114 -0
- docketeer_autonomy-0.0.16/tests/test_prompt.py +55 -0
- docketeer_autonomy-0.0.16/tests/test_rooms.py +77 -0
|
@@ -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,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()
|