power-loop 0.7.2__tar.gz → 0.8.0__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.
- {power_loop-0.7.2 → power_loop-0.8.0}/PKG-INFO +1 -1
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/__init__.py +13 -1
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/agent/types.py +4 -0
- power_loop-0.8.0/power_loop/runtime/notes.py +194 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/session_store.py +111 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/tools/default_manifest.py +48 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/tools/default_tools.py +48 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop.egg-info/PKG-INFO +1 -1
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop.egg-info/SOURCES.txt +1 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/LICENSE +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/README.md +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/__init__.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/anthropic_factory.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/capabilities.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/interface.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/llm_factory.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/llm_tooling.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/llm_utils.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/multimodal.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/qwen_image.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/llm_client/web_search.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/agent/__init__.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/agent/follow_up.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/agent/sink.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/agent/stateful_loop.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/agent/system_prompt.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/__init__.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/errors.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/event_payloads.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/events.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/handlers.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/hook_contexts.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/hooks.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/messages.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/protocols.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/contracts/tools.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/core/agent_context.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/core/events.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/core/hooks.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/core/phase.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/core/pipeline.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/core/runner.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/core/state.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/budget.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/cancellation.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/compact.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/env.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/exec_backend.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/human_input.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/memory.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/provider.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/retry.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/runtime_state.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/skills.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/spec.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/runtime/structured.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/tools/__init__.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/tools/registry.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop/tools/spawn_agent.py +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop.egg-info/dependency_links.txt +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop.egg-info/requires.txt +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/power_loop.egg-info/top_level.txt +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/pyproject.toml +0 -0
- {power_loop-0.7.2 → power_loop-0.8.0}/setup.cfg +0 -0
|
@@ -22,7 +22,7 @@ Stability tiers
|
|
|
22
22
|
无版本承诺,可随时变更或删除。
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
|
-
__version__ = "0.
|
|
25
|
+
__version__ = "0.8.0"
|
|
26
26
|
|
|
27
27
|
from power_loop.agent.follow_up import FollowUpQueued
|
|
28
28
|
from power_loop.agent.sink import MessageSink, NullSink, SQLiteSink
|
|
@@ -124,6 +124,13 @@ from power_loop.runtime.exec_backend import (
|
|
|
124
124
|
)
|
|
125
125
|
from power_loop.runtime.human_input import HumanInputRequired, request_user_input
|
|
126
126
|
from power_loop.runtime.memory import MemoryProvider, MemorySnapshot, tag_as_memory
|
|
127
|
+
from power_loop.runtime.notes import (
|
|
128
|
+
DEFAULT_NOTES_POLICY,
|
|
129
|
+
NotesFullError,
|
|
130
|
+
NotesPolicy,
|
|
131
|
+
SQLiteNoteMemory,
|
|
132
|
+
render_notes,
|
|
133
|
+
)
|
|
127
134
|
from power_loop.runtime.provider import (
|
|
128
135
|
LLMProviderConfig,
|
|
129
136
|
create_llm_service_from_config,
|
|
@@ -256,6 +263,11 @@ __all__ = [
|
|
|
256
263
|
"MemoryProvider",
|
|
257
264
|
"MemorySnapshot",
|
|
258
265
|
"tag_as_memory",
|
|
266
|
+
"NotesPolicy",
|
|
267
|
+
"NotesFullError",
|
|
268
|
+
"SQLiteNoteMemory",
|
|
269
|
+
"DEFAULT_NOTES_POLICY",
|
|
270
|
+
"render_notes",
|
|
259
271
|
"MemoryRecalledCtx",
|
|
260
272
|
"MemoryRecalledPayload",
|
|
261
273
|
"MemoryFailedPayload",
|
|
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
6
6
|
if TYPE_CHECKING:
|
|
7
7
|
from power_loop.runtime.compact import Compactor
|
|
8
8
|
from power_loop.runtime.memory import MemoryProvider
|
|
9
|
+
from power_loop.runtime.notes import NotesPolicy
|
|
9
10
|
from power_loop.runtime.retry import LLMRetryPolicy
|
|
10
11
|
from power_loop.runtime.runtime_state import RuntimeProjector
|
|
11
12
|
|
|
@@ -35,6 +36,9 @@ class AgentLoopConfig:
|
|
|
35
36
|
retry_policy: LLMRetryPolicy | None = None
|
|
36
37
|
memory: MemoryProvider | None = None
|
|
37
38
|
memory_budget_tokens: int = 1500
|
|
39
|
+
# Bounds for the note_add/note_update/note_delete tools (agent-authored
|
|
40
|
+
# notes). None → DEFAULT_NOTES_POLICY. See power_loop.runtime.notes.
|
|
41
|
+
notes_policy: NotesPolicy | None = None
|
|
38
42
|
skills_dir: str | None = None
|
|
39
43
|
runtime_projectors: tuple[RuntimeProjector, ...] = field(default_factory=_default_runtime_projectors)
|
|
40
44
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Agent-authored persistent notes ("self-managed memory").
|
|
2
|
+
|
|
3
|
+
Complements :mod:`power_loop.runtime.memory`:
|
|
4
|
+
|
|
5
|
+
* ``MemoryProvider`` is the *passive* seam — recall/remember at session
|
|
6
|
+
boundaries, the provider decides what to keep (summaries, facts, RAG).
|
|
7
|
+
* Notes are *agent-driven* memory — the model itself decides mid-conversation
|
|
8
|
+
what is worth keeping, via the ``note_add`` / ``note_update`` /
|
|
9
|
+
``note_delete`` default tools, persisted in the session store's ``notes``
|
|
10
|
+
table.
|
|
11
|
+
|
|
12
|
+
The two meet in :class:`SQLiteNoteMemory`: a ``MemoryProvider`` whose
|
|
13
|
+
``recall()`` renders the session's notes into one system message injected at
|
|
14
|
+
every send (so the model always sees its own notes), and whose ``remember()``
|
|
15
|
+
is a no-op (writes already happened through the tools, in real time).
|
|
16
|
+
|
|
17
|
+
Capacity model
|
|
18
|
+
--------------
|
|
19
|
+
``NotesPolicy`` bounds everything. The default eviction mode is **reject**:
|
|
20
|
+
when the table is full, ``note_add`` fails with an instructive error telling
|
|
21
|
+
the model to delete or merge first. Silent loss is the worst failure mode for
|
|
22
|
+
an agent's memory — being forced to curate beats quietly forgetting.
|
|
23
|
+
``eviction="fifo"`` switches to queue semantics: the oldest unpinned note is
|
|
24
|
+
dropped to make room (pinned notes are never auto-evicted).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Any, Literal
|
|
31
|
+
|
|
32
|
+
from power_loop.runtime.memory import LoopMessage
|
|
33
|
+
from power_loop.runtime.session_store import NoteRow, SessionStore
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotesFullError(ValueError):
|
|
37
|
+
"""note_add was refused because the session is at max_notes (reject mode)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class NotesPolicy:
|
|
42
|
+
"""Bounds for agent-authored notes.
|
|
43
|
+
|
|
44
|
+
max_notes : per-session note count cap.
|
|
45
|
+
max_note_chars : per-note content length cap (longer content is rejected,
|
|
46
|
+
never truncated silently).
|
|
47
|
+
inject_max_chars : budget for the rendered recall message; oldest unpinned
|
|
48
|
+
notes are elided first, and the rendering says how many
|
|
49
|
+
were hidden so the model knows its memory view is partial.
|
|
50
|
+
eviction : "reject" (default) — note_add errors when full;
|
|
51
|
+
"fifo" — drop the oldest unpinned note to make room.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
max_notes: int = 50
|
|
55
|
+
max_note_chars: int = 1000
|
|
56
|
+
inject_max_chars: int = 8000
|
|
57
|
+
eviction: Literal["reject", "fifo"] = "reject"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
DEFAULT_NOTES_POLICY = NotesPolicy()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_note_checked(
|
|
64
|
+
store: SessionStore,
|
|
65
|
+
session_id: str,
|
|
66
|
+
content: str,
|
|
67
|
+
*,
|
|
68
|
+
pinned: bool = False,
|
|
69
|
+
policy: NotesPolicy = DEFAULT_NOTES_POLICY,
|
|
70
|
+
) -> NoteRow:
|
|
71
|
+
"""Policy-enforcing insert used by the ``note_add`` tool handler."""
|
|
72
|
+
content = content.strip()
|
|
73
|
+
if not content:
|
|
74
|
+
raise ValueError("note content is empty")
|
|
75
|
+
if len(content) > policy.max_note_chars:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"note too long ({len(content)} chars > {policy.max_note_chars}); "
|
|
78
|
+
"keep notes short — store details in a file instead"
|
|
79
|
+
)
|
|
80
|
+
if store.count_notes(session_id) >= policy.max_notes:
|
|
81
|
+
if policy.eviction == "fifo":
|
|
82
|
+
for note in store.list_notes(session_id):
|
|
83
|
+
if not note.pinned:
|
|
84
|
+
store.delete_note(session_id, note.note_id)
|
|
85
|
+
break
|
|
86
|
+
else:
|
|
87
|
+
raise NotesFullError(
|
|
88
|
+
f"notes are full ({policy.max_notes}) and all are pinned; "
|
|
89
|
+
"unpin or delete (note_delete) before adding"
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
raise NotesFullError(
|
|
93
|
+
f"notes are full ({policy.max_notes}); delete stale notes with "
|
|
94
|
+
"note_delete or merge related ones with note_update first"
|
|
95
|
+
)
|
|
96
|
+
return store.add_note(session_id, content, pinned=pinned)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def update_note_checked(
|
|
100
|
+
store: SessionStore,
|
|
101
|
+
session_id: str,
|
|
102
|
+
note_id: int,
|
|
103
|
+
*,
|
|
104
|
+
content: str | None = None,
|
|
105
|
+
pinned: bool | None = None,
|
|
106
|
+
policy: NotesPolicy = DEFAULT_NOTES_POLICY,
|
|
107
|
+
) -> None:
|
|
108
|
+
if content is not None:
|
|
109
|
+
content = content.strip()
|
|
110
|
+
if not content:
|
|
111
|
+
raise ValueError("note content is empty; use note_delete to remove a note")
|
|
112
|
+
if len(content) > policy.max_note_chars:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"note too long ({len(content)} chars > {policy.max_note_chars})"
|
|
115
|
+
)
|
|
116
|
+
if content is None and pinned is None:
|
|
117
|
+
raise ValueError("nothing to update — pass content and/or pinned")
|
|
118
|
+
if not store.update_note(session_id, note_id, content=content, pinned=pinned):
|
|
119
|
+
raise ValueError(f"note #{note_id} does not exist")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def render_notes(notes: list[NoteRow], *, policy: NotesPolicy = DEFAULT_NOTES_POLICY) -> str:
|
|
123
|
+
"""Render notes as the text block injected at recall time.
|
|
124
|
+
|
|
125
|
+
Pinned notes always survive the character budget; unpinned notes are
|
|
126
|
+
elided oldest-first when over budget, with an explicit elision marker.
|
|
127
|
+
"""
|
|
128
|
+
if not notes:
|
|
129
|
+
return ""
|
|
130
|
+
pinned = [n for n in notes if n.pinned]
|
|
131
|
+
unpinned = [n for n in notes if not n.pinned]
|
|
132
|
+
|
|
133
|
+
def line(n: NoteRow) -> str:
|
|
134
|
+
flag = " [pinned]" if n.pinned else ""
|
|
135
|
+
return f"#{n.note_id}{flag} {n.content}"
|
|
136
|
+
|
|
137
|
+
budget = policy.inject_max_chars
|
|
138
|
+
kept_unpinned: list[NoteRow] = []
|
|
139
|
+
used = sum(len(line(n)) + 1 for n in pinned)
|
|
140
|
+
# Keep the newest unpinned notes within budget (drop oldest first).
|
|
141
|
+
for n in reversed(unpinned):
|
|
142
|
+
cost = len(line(n)) + 1
|
|
143
|
+
if used + cost > budget:
|
|
144
|
+
break
|
|
145
|
+
kept_unpinned.append(n)
|
|
146
|
+
used += cost
|
|
147
|
+
kept_unpinned.reverse()
|
|
148
|
+
hidden = len(unpinned) - len(kept_unpinned)
|
|
149
|
+
|
|
150
|
+
shown = sorted(pinned + kept_unpinned, key=lambda n: n.note_id)
|
|
151
|
+
lines = [
|
|
152
|
+
"YOUR NOTES (persistent memory you maintain with note_add / note_update / "
|
|
153
|
+
"note_delete; survives context compaction):"
|
|
154
|
+
]
|
|
155
|
+
lines.extend(line(n) for n in shown)
|
|
156
|
+
if hidden > 0:
|
|
157
|
+
lines.append(f"(… {hidden} older note(s) hidden by budget — consolidate or delete)")
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SQLiteNoteMemory:
|
|
162
|
+
"""``MemoryProvider`` that injects the session's own notes every send.
|
|
163
|
+
|
|
164
|
+
``remember()`` is a no-op: the notes table is mutated in real time by the
|
|
165
|
+
note tools, so there is nothing to persist at session end.
|
|
166
|
+
|
|
167
|
+
Pass the **same** :class:`SessionStore` the loop uses (or rely on the loop
|
|
168
|
+
sharing its store): ``StatefulAgentLoop(store=store,
|
|
169
|
+
config=AgentLoopConfig(memory=SQLiteNoteMemory(store)))``.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self, store: SessionStore, *, policy: NotesPolicy = DEFAULT_NOTES_POLICY
|
|
174
|
+
) -> None:
|
|
175
|
+
self._store = store
|
|
176
|
+
self._policy = policy
|
|
177
|
+
|
|
178
|
+
async def recall(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
messages: list[LoopMessage],
|
|
182
|
+
session_id: str | None,
|
|
183
|
+
budget_tokens: int = 1500,
|
|
184
|
+
) -> list[LoopMessage]:
|
|
185
|
+
if session_id is None:
|
|
186
|
+
return []
|
|
187
|
+
notes = self._store.list_notes(session_id)
|
|
188
|
+
text = render_notes(notes, policy=self._policy)
|
|
189
|
+
if not text:
|
|
190
|
+
return []
|
|
191
|
+
return [{"role": "system", "name": "memory_notes", "content": text}]
|
|
192
|
+
|
|
193
|
+
async def remember(self, snapshot: Any) -> None: # noqa: ARG002
|
|
194
|
+
return None
|
|
@@ -8,6 +8,7 @@ Owns the persistence layer for stateful agent loops:
|
|
|
8
8
|
- session_state : single-row per-session mutable state (next_seq, pending, …)
|
|
9
9
|
- session_runtime_state : per-session JSON state for tool/runtime protocols
|
|
10
10
|
- background_tasks : persisted background command task status
|
|
11
|
+
- notes : agent-authored persistent notes (self-managed memory)
|
|
11
12
|
|
|
12
13
|
The store is the **only** place that writes to disk. Callers (StatefulAgentLoop,
|
|
13
14
|
the Sink that the pipeline drives, the subagent runtime) all go through here.
|
|
@@ -150,6 +151,16 @@ CREATE TABLE IF NOT EXISTS background_tasks (
|
|
|
150
151
|
);
|
|
151
152
|
CREATE INDEX IF NOT EXISTS idx_background_tasks_session_status
|
|
152
153
|
ON background_tasks(session_id, status, updated_at);
|
|
154
|
+
|
|
155
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
156
|
+
session_id TEXT NOT NULL,
|
|
157
|
+
note_id INTEGER NOT NULL,
|
|
158
|
+
content TEXT NOT NULL,
|
|
159
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
160
|
+
created_at INTEGER NOT NULL,
|
|
161
|
+
updated_at INTEGER NOT NULL,
|
|
162
|
+
PRIMARY KEY (session_id, note_id)
|
|
163
|
+
);
|
|
153
164
|
"""
|
|
154
165
|
|
|
155
166
|
|
|
@@ -221,6 +232,19 @@ class BackgroundTaskRow:
|
|
|
221
232
|
updated_at: int
|
|
222
233
|
|
|
223
234
|
|
|
235
|
+
@dataclass
|
|
236
|
+
class NoteRow:
|
|
237
|
+
"""One agent-authored note. ``note_id`` is a short per-session integer so
|
|
238
|
+
the model can reference notes naturally ("update note 3")."""
|
|
239
|
+
|
|
240
|
+
session_id: str
|
|
241
|
+
note_id: int
|
|
242
|
+
content: str
|
|
243
|
+
pinned: bool
|
|
244
|
+
created_at: int
|
|
245
|
+
updated_at: int
|
|
246
|
+
|
|
247
|
+
|
|
224
248
|
def _now_ms() -> int:
|
|
225
249
|
return time.time_ns() // 1_000_000
|
|
226
250
|
|
|
@@ -389,6 +413,7 @@ class SessionStore:
|
|
|
389
413
|
self._conn.execute("DELETE FROM usage_rounds WHERE session_id=?", (session_id,))
|
|
390
414
|
self._conn.execute("DELETE FROM session_runtime_state WHERE session_id=?", (session_id,))
|
|
391
415
|
self._conn.execute("DELETE FROM background_tasks WHERE session_id=?", (session_id,))
|
|
416
|
+
self._conn.execute("DELETE FROM notes WHERE session_id=?", (session_id,))
|
|
392
417
|
self._conn.execute("DELETE FROM session_state WHERE session_id=?", (session_id,))
|
|
393
418
|
cur = self._conn.execute("DELETE FROM sessions WHERE session_id=?", (session_id,))
|
|
394
419
|
deleted += cur.rowcount
|
|
@@ -770,6 +795,92 @@ class SessionStore:
|
|
|
770
795
|
(now, session_id, *task_ids),
|
|
771
796
|
)
|
|
772
797
|
|
|
798
|
+
# ── notes (agent-authored persistent memory) ──────────────────────────
|
|
799
|
+
|
|
800
|
+
def add_note(self, session_id: str, content: str, *, pinned: bool = False) -> NoteRow:
|
|
801
|
+
"""Insert a note with the next per-session note_id and return it."""
|
|
802
|
+
now = _now_ms()
|
|
803
|
+
with self._lock, self._conn:
|
|
804
|
+
row = self._conn.execute(
|
|
805
|
+
"SELECT COALESCE(MAX(note_id), 0) + 1 AS nid FROM notes WHERE session_id=?",
|
|
806
|
+
(session_id,),
|
|
807
|
+
).fetchone()
|
|
808
|
+
note_id = int(row["nid"])
|
|
809
|
+
self._conn.execute(
|
|
810
|
+
"""
|
|
811
|
+
INSERT INTO notes (session_id, note_id, content, pinned, created_at, updated_at)
|
|
812
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
813
|
+
""",
|
|
814
|
+
(session_id, note_id, content, 1 if pinned else 0, now, now),
|
|
815
|
+
)
|
|
816
|
+
return NoteRow(
|
|
817
|
+
session_id=session_id,
|
|
818
|
+
note_id=note_id,
|
|
819
|
+
content=content,
|
|
820
|
+
pinned=pinned,
|
|
821
|
+
created_at=now,
|
|
822
|
+
updated_at=now,
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
def update_note(
|
|
826
|
+
self,
|
|
827
|
+
session_id: str,
|
|
828
|
+
note_id: int,
|
|
829
|
+
*,
|
|
830
|
+
content: str | None = None,
|
|
831
|
+
pinned: bool | None = None,
|
|
832
|
+
) -> bool:
|
|
833
|
+
"""Update content and/or pinned flag. Returns False if the note doesn't exist."""
|
|
834
|
+
sets: list[str] = ["updated_at=?"]
|
|
835
|
+
params: list[Any] = [_now_ms()]
|
|
836
|
+
if content is not None:
|
|
837
|
+
sets.append("content=?")
|
|
838
|
+
params.append(content)
|
|
839
|
+
if pinned is not None:
|
|
840
|
+
sets.append("pinned=?")
|
|
841
|
+
params.append(1 if pinned else 0)
|
|
842
|
+
params.extend([session_id, note_id])
|
|
843
|
+
with self._lock, self._conn:
|
|
844
|
+
cur = self._conn.execute(
|
|
845
|
+
f"UPDATE notes SET {', '.join(sets)} WHERE session_id=? AND note_id=?",
|
|
846
|
+
params,
|
|
847
|
+
)
|
|
848
|
+
return cur.rowcount > 0
|
|
849
|
+
|
|
850
|
+
def delete_note(self, session_id: str, note_id: int) -> bool:
|
|
851
|
+
with self._lock, self._conn:
|
|
852
|
+
cur = self._conn.execute(
|
|
853
|
+
"DELETE FROM notes WHERE session_id=? AND note_id=?",
|
|
854
|
+
(session_id, note_id),
|
|
855
|
+
)
|
|
856
|
+
return cur.rowcount > 0
|
|
857
|
+
|
|
858
|
+
def list_notes(self, session_id: str) -> list[NoteRow]:
|
|
859
|
+
"""All notes for a session in note_id (= creation) order."""
|
|
860
|
+
with self._lock:
|
|
861
|
+
rows = self._conn.execute(
|
|
862
|
+
"SELECT * FROM notes WHERE session_id=? ORDER BY note_id",
|
|
863
|
+
(session_id,),
|
|
864
|
+
).fetchall()
|
|
865
|
+
return [
|
|
866
|
+
NoteRow(
|
|
867
|
+
session_id=r["session_id"],
|
|
868
|
+
note_id=int(r["note_id"]),
|
|
869
|
+
content=r["content"],
|
|
870
|
+
pinned=bool(r["pinned"]),
|
|
871
|
+
created_at=int(r["created_at"]),
|
|
872
|
+
updated_at=int(r["updated_at"]),
|
|
873
|
+
)
|
|
874
|
+
for r in rows
|
|
875
|
+
]
|
|
876
|
+
|
|
877
|
+
def count_notes(self, session_id: str) -> int:
|
|
878
|
+
with self._lock:
|
|
879
|
+
row = self._conn.execute(
|
|
880
|
+
"SELECT COUNT(*) AS n FROM notes WHERE session_id=?", (session_id,)
|
|
881
|
+
).fetchone()
|
|
882
|
+
return int(row["n"])
|
|
883
|
+
|
|
773
884
|
# ── usage ─────────────────────────────────────────────────────────────
|
|
774
885
|
|
|
775
886
|
def record_usage(
|
|
@@ -185,6 +185,54 @@ DEFAULT_TOOL_DEFINITIONS: list[ToolDefinition] = [
|
|
|
185
185
|
},
|
|
186
186
|
required_params=("items",),
|
|
187
187
|
),
|
|
188
|
+
ToolDefinition(
|
|
189
|
+
name="note_add",
|
|
190
|
+
description=(
|
|
191
|
+
"Save a short persistent note to your own memory. Notes survive context compaction and are "
|
|
192
|
+
"shown back to you at the start of every turn. Use for durable facts, preferences, ongoing "
|
|
193
|
+
"task state — not for transcripts or long content (put those in files). When notes are full "
|
|
194
|
+
"you must delete or merge old ones first. Set pinned=true for notes that must never be "
|
|
195
|
+
"hidden or auto-evicted."
|
|
196
|
+
),
|
|
197
|
+
input_schema={
|
|
198
|
+
"type": "object",
|
|
199
|
+
"properties": {
|
|
200
|
+
"content": {"type": "string", "description": "The note text. Keep it short and self-contained."},
|
|
201
|
+
"pinned": {"type": "boolean", "description": "Pinned notes are always visible and never auto-evicted. Default false."},
|
|
202
|
+
},
|
|
203
|
+
"required": ["content"],
|
|
204
|
+
},
|
|
205
|
+
required_params=("content",),
|
|
206
|
+
),
|
|
207
|
+
ToolDefinition(
|
|
208
|
+
name="note_update",
|
|
209
|
+
description=(
|
|
210
|
+
"Rewrite or (un)pin one of your existing notes by its #id. Use to keep memory current "
|
|
211
|
+
"instead of piling up near-duplicates, and to merge related notes into one."
|
|
212
|
+
),
|
|
213
|
+
input_schema={
|
|
214
|
+
"type": "object",
|
|
215
|
+
"properties": {
|
|
216
|
+
"note_id": {"type": "integer", "description": "The #id shown in YOUR NOTES."},
|
|
217
|
+
"content": {"type": "string", "description": "New text replacing the old content."},
|
|
218
|
+
"pinned": {"type": "boolean", "description": "Set or clear the pinned flag."},
|
|
219
|
+
},
|
|
220
|
+
"required": ["note_id"],
|
|
221
|
+
},
|
|
222
|
+
required_params=("note_id",),
|
|
223
|
+
),
|
|
224
|
+
ToolDefinition(
|
|
225
|
+
name="note_delete",
|
|
226
|
+
description="Delete one of your notes by its #id when it is stale or no longer worth remembering.",
|
|
227
|
+
input_schema={
|
|
228
|
+
"type": "object",
|
|
229
|
+
"properties": {
|
|
230
|
+
"note_id": {"type": "integer", "description": "The #id shown in YOUR NOTES."},
|
|
231
|
+
},
|
|
232
|
+
"required": ["note_id"],
|
|
233
|
+
},
|
|
234
|
+
required_params=("note_id",),
|
|
235
|
+
),
|
|
188
236
|
ToolDefinition(
|
|
189
237
|
name="background_run",
|
|
190
238
|
description="Run a shell command in a private background worker (non-interactive).",
|
|
@@ -1135,6 +1135,49 @@ def run_todo(items: list[dict[str, Any]]) -> str:
|
|
|
1135
1135
|
return get_ctx().todo.update(validated)
|
|
1136
1136
|
|
|
1137
1137
|
|
|
1138
|
+
def _notes_policy() -> Any:
|
|
1139
|
+
"""Resolve the active NotesPolicy: loop config override or the default."""
|
|
1140
|
+
from power_loop.runtime.notes import DEFAULT_NOTES_POLICY
|
|
1141
|
+
|
|
1142
|
+
runtime_ctx = get_tool_runtime_context()
|
|
1143
|
+
policy = getattr(runtime_ctx.config, "notes_policy", None) if runtime_ctx.config else None
|
|
1144
|
+
return policy or DEFAULT_NOTES_POLICY
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _notes_store_and_session() -> tuple[Any, str]:
|
|
1148
|
+
store, sid = _current_store_and_session()
|
|
1149
|
+
if store is None or sid is None:
|
|
1150
|
+
raise RuntimeError("note tools need an active session (StatefulAgentLoop)")
|
|
1151
|
+
return store, sid
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def run_note_add(content: str, pinned: bool = False) -> str:
|
|
1155
|
+
from power_loop.runtime.notes import add_note_checked
|
|
1156
|
+
|
|
1157
|
+
store, sid = _notes_store_and_session()
|
|
1158
|
+
policy = _notes_policy()
|
|
1159
|
+
note = add_note_checked(store, sid, content, pinned=pinned, policy=policy)
|
|
1160
|
+
count = store.count_notes(sid)
|
|
1161
|
+
return f"noted as #{note.note_id} ({count}/{policy.max_notes} notes used)"
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def run_note_update(note_id: int, content: str | None = None, pinned: bool | None = None) -> str:
|
|
1165
|
+
from power_loop.runtime.notes import update_note_checked
|
|
1166
|
+
|
|
1167
|
+
store, sid = _notes_store_and_session()
|
|
1168
|
+
update_note_checked(
|
|
1169
|
+
store, sid, int(note_id), content=content, pinned=pinned, policy=_notes_policy()
|
|
1170
|
+
)
|
|
1171
|
+
return f"note #{note_id} updated"
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def run_note_delete(note_id: int) -> str:
|
|
1175
|
+
store, sid = _notes_store_and_session()
|
|
1176
|
+
if not store.delete_note(sid, int(note_id)):
|
|
1177
|
+
raise ValueError(f"note #{note_id} does not exist")
|
|
1178
|
+
return f"note #{note_id} deleted ({store.count_notes(sid)} remaining)"
|
|
1179
|
+
|
|
1180
|
+
|
|
1138
1181
|
DEFAULT_TOOL_HANDLERS: dict[str, Any] = {
|
|
1139
1182
|
"bash": lambda **kw: run_bash(kw.get("command"), kw.get("restart", False), kw.get("timeout", 120)),
|
|
1140
1183
|
"read_file": lambda **kw: run_read(kw["path"], kw.get("offset"), kw.get("limit")),
|
|
@@ -1158,6 +1201,11 @@ DEFAULT_TOOL_HANDLERS: dict[str, Any] = {
|
|
|
1158
1201
|
),
|
|
1159
1202
|
"load_skill": lambda **kw: run_load_skill(kw["name"]),
|
|
1160
1203
|
"todo": lambda **kw: run_todo(kw["items"]),
|
|
1204
|
+
"note_add": lambda **kw: run_note_add(kw["content"], kw.get("pinned", False)),
|
|
1205
|
+
"note_update": lambda **kw: run_note_update(
|
|
1206
|
+
kw["note_id"], kw.get("content"), kw.get("pinned")
|
|
1207
|
+
),
|
|
1208
|
+
"note_delete": lambda **kw: run_note_delete(kw["note_id"]),
|
|
1161
1209
|
"background_run": lambda **kw: run_background(kw["command"]),
|
|
1162
1210
|
"check_background": lambda **kw: check_background(kw.get("task_id")),
|
|
1163
1211
|
"request_user_input": lambda **kw: request_user_input(
|
|
@@ -47,6 +47,7 @@ power_loop/runtime/env.py
|
|
|
47
47
|
power_loop/runtime/exec_backend.py
|
|
48
48
|
power_loop/runtime/human_input.py
|
|
49
49
|
power_loop/runtime/memory.py
|
|
50
|
+
power_loop/runtime/notes.py
|
|
50
51
|
power_loop/runtime/provider.py
|
|
51
52
|
power_loop/runtime/retry.py
|
|
52
53
|
power_loop/runtime/runtime_state.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|