feat: structured memory system — typed/scoped memories with BM25 rele… (#53)

* feat: structured memory system — typed/scoped memories with BM25 relevance and metacognitive prompting

Replace flat key-value memories table with structured_memories (migration 014).
Four memory types (user/project/feedback/reference), three scopes
(global/workstream/user). Consolidate remember/recall/forget into two tools:
memory (action-based: save/search/delete/list) and recall (conversation
history only).

BM25 relevance scoring (extracted to turnstone/core/bm25.py) selects top-5
memories for system message injection based on conversation context.
Metacognitive prompting injects ephemeral nudges after corrections, tool
denials, workstream resume, and completion signals.

Scope isolation enforced: system message injection and nudge counts filtered
to visible memories only (global + current workstream + authenticated user).
User scope requires authentication. Content capped at 32KB. ILIKE/LIKE
metacharacters escaped in both backends.

113 new tests (2053 total).

* fix: CI failure + copilot review feedback

- Fix time.monotonic() cooldown: use None sentinel instead of 0.0
  default (monotonic clock starts at boot, not epoch — fresh CI
  runners have uptime < 300s so cooldown check always triggered)
- Catch sa.exc.IntegrityError specifically in upsert instead of
  broad Exception (copilot review)
- Preserve existing description/type on upsert when caller doesn't
  explicitly set them (copilot review)
- Add last_accessed + access_count columns to schema/migration for
  future LRU/LFU eviction support
This commit is contained in:
Patrick Buckley
2026-03-13 21:21:09 -07:00
committed by GitHub
parent 73cacc8ad6
commit 723cad24bb
33 changed files with 1968 additions and 474 deletions
+3 -4
View File
@@ -193,7 +193,7 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
## Tools
16 built-in tools, 2 agent tools, plus external tools via MCP:
15 built-in tools, 2 agent tools, plus external tools via MCP:
| Tool | Description | Auto-approved |
|------|-------------|:---:|
@@ -206,9 +206,8 @@ Bridges BLPOP from their per-node queue (priority) then the shared queue. Direct
| `man` | Read man pages | yes |
| `web_fetch` | Fetch URL content | |
| `web_search` | Web search (provider-native or Tavily) | |
| `remember` | Save persistent facts | yes |
| `recall` | Search memories and history | yes |
| `forget` | Remove a memory | yes |
| `memory` | Structured persistent memory (save/search/delete/list) | yes |
| `recall` | Search conversation history | yes |
| `notify` | Send notifications to linked channels | yes |
| `watch` | Periodic command polling with conditions | |
| `task` | Spawn autonomous sub-agent | |
+5 -6
View File
@@ -3,7 +3,7 @@
Turnstone is an AI orchestration platform with tool use, parallel workstreams, and persistent
memory. It connects to any OpenAI-compatible API (local vLLM, OpenAI, etc.) or
Anthropic's native Messages API via pluggable provider adapters, and gives the
model 18 built-in tools plus external tools via MCP (Model Context Protocol) for
model 17 built-in tools plus external tools via MCP (Model Context Protocol) for
reading, writing, searching, planning, and executing code.
The core design principle is a **UI-agnostic engine with pluggable frontends**.
@@ -436,13 +436,13 @@ from each schema and builds:
- `PRIMARY_KEY_MAP` -- `{name: primary_key}` for JSON fallback recovery
- `merge_mcp_tools(builtin, mcp_tools)` -- merges built-in + MCP tools at session init
### 14 Tools by Category
### 13 Tools by Category
**Read-only (auto-approve)**:
- `read_file` -- read file contents with optional offset/limit
- `search` -- ripgrep-based codebase search
- `man` -- read man pages
- `recall` -- retrieve stored memories
- `recall` -- search conversation history
**Write (requires approval)**:
- `bash` -- execute shell commands (with safety checks via `turnstone.core.safety`)
@@ -456,9 +456,8 @@ from each schema and builds:
- `task` -- delegate to a sub-agent with full tool access (`TASK_AGENT_TOOLS`)
- `plan` -- explore codebase and write a structured plan (`AGENT_TOOLS`)
**Memory (persistent key-value store)**:
- `remember` -- save a fact
- `forget` -- delete a fact
**Memory (structured persistent store)**:
- `memory` -- save, search, delete, or list memories (typed and scoped)
### Prepare / Execute Pattern
+1 -1
View File
@@ -127,7 +127,7 @@ group loop [while tool_calls present]
math → sandboxed subprocess
web_fetch → httpx + LLM summarize
web_search → provider-native or Tavily fallback
remember/recall/forget → SQLite
memory/recall → SQLite
end note
note right of TP
+4 -6
View File
@@ -24,7 +24,7 @@ partition "Phase 1: Prepare" #E8F5E9 {
:Dispatch to _prepare_{func_name}();
note right
**Dispatch table (18 tools):**
**Dispatch table (17 tools):**
┌───────────────┬──────────────────┐
│ Tool │ Needs Approval? │
├───────────────┼──────────────────┤
@@ -40,9 +40,8 @@ partition "Phase 1: Prepare" #E8F5E9 {
│ tool_search │ ✗ Auto-approve │
│ task │ ✓ Yes │
│ plan │ ✓ Yes │
remember │ ✗ Auto-approve │
│ memory │ ✗ Auto-approve │
│ recall │ ✗ Auto-approve │
│ forget │ ✗ Auto-approve │
│ notify │ ✗ Auto-approve │
│ read_resource │ ✓ Yes │
│ use_prompt │ ✓ Yes │
@@ -116,9 +115,8 @@ partition "Phase 3: Execute" #E3F2FD {
├─ _exec_task: _run_agent(TASK_AGENT_TOOLS)
├─ _exec_plan: _run_agent(AGENT_TOOLS, read-only)
├─ _exec_notify: HTTP POST to channel gateway
├─ _exec_remember: SQLite INSERT OR REPLACE
├─ _exec_recall: SQLite FTS5/LIKE search
├─ _exec_forget: SQLite DELETE
├─ _exec_memory: structured memory save/search/delete/list
├─ _exec_recall: conversation history FTS5 search
├─ _exec_read_resource: MCPClientManager.read_resource_sync()
├─ _exec_use_prompt: MCPClientManager.get_prompt_sync()
└─ _exec_mcp_tool: MCPClientManager.call_tool_sync()
+1 -1
View File
@@ -79,7 +79,7 @@ note right of BridgeA
1. _ws_auto_approve[ws_id]? → auto
2. All tools in safe set? → auto
(read_file, search, man,
remember, recall, forget)
memory, recall)
3. Otherwise → manual approval
end note
+27 -38
View File
@@ -1,6 +1,6 @@
# Tools Reference
turnstone exposes 18 built-in tools plus any number of external MCP tools to the
turnstone exposes 17 built-in tools plus any number of external MCP tools to the
LLM via the OpenAI function-calling interface. Built-in tools are defined as JSON
files under `turnstone/tools/` and loaded at startup by `turnstone/core/tools.py`.
MCP tools are discovered from configured MCP servers at startup by
@@ -46,12 +46,12 @@ schema plus turnstone-specific metadata keys:
| Name | Description |
|---------------------|-------------|
| `TOOLS` | All 18 tool definitions (sent to the model). |
| `TOOLS` | All 17 tool definitions (sent to the model). |
| `AGENT_TOOLS` | Tools with `agent: true` -- available to plan sub-agents. Read-only tools. |
| `TASK_AGENT_TOOLS` | Tools with `task_agent: true` -- available to task sub-agents. Includes write operations. |
| `AGENT_AUTO_TOOLS` | Set of tool names with `auto_approve: true` -- no user confirmation needed. |
| `TASK_AUTO_TOOLS` | Same as `AGENT_AUTO_TOOLS` (identical filter). |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 18 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `BUILTIN_TOOL_NAMES`| Frozenset of all 17 built-in tool names. Used by tool search to distinguish always-on tools from deferrable MCP tools. |
| `PRIMARY_KEY_MAP` | Dict mapping tool name to its `primary_key` parameter name. |
---
@@ -69,7 +69,7 @@ Tool execution follows a three-phase pipeline inside `ChatSession._execute_tools
- Parses the JSON arguments (with fallback for malformed JSON).
- If JSON parsing fails entirely, uses `PRIMARY_KEY_MAP` to map a bare string
to the correct parameter.
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 18
- Dispatches to the matching `_prepare_{func_name}()` handler. There are 17
built-in tools plus `tool_search` (synthetic, client-side BM25 fallback) and
the generic `_prepare_mcp_tool()` handler for MCP tools.
- Validates arguments and builds a preview dict containing:
@@ -114,9 +114,8 @@ Each item's `execute` callable is invoked:
- `read_file` -- reads files, no side effects
- `search` -- grep-style search, no side effects
- `man` -- reads man pages, no side effects
- `remember` -- writes to persistent memory database (lightweight, always auto-approved)
- `recall` -- reads from persistent memory database
- `forget` -- deletes from persistent memory database (lightweight, always auto-approved)
- `memory` -- structured persistent memory (save/search/delete/list)
- `recall` -- searches conversation history
- `notify` -- sends notifications to linked channels (time-sensitive, auto-approved for urgency)
**Requires user confirmation** (write operations, network access, side effects):
@@ -164,9 +163,8 @@ Every tool defines a `primary_key`. The mapping is:
| `web_search` | `query` |
| `task` | `prompt` |
| `plan` | `prompt` |
| `remember` | `key` |
| `memory` | `name` |
| `recall` | `query` |
| `forget` | `key` |
| `notify` | `message` |
| `read_resource` | `uri` |
| `use_prompt` | `name` |
@@ -353,16 +351,22 @@ Plan before implementing -- an autonomous agent explores the codebase and writes
## Memory
### remember
### memory
Save a persistent memory that persists across sessions.
Structured persistent memory across sessions with typed, scoped entries.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `key` | string | yes | Short identifier (e.g. `user_name`). |
| `value` | string | yes | Content to remember. |
| Parameter | Type | Required | Description |
|---------------|---------|----------|-------------|
| `action` | string | yes | `save`, `search`, `delete`, or `list`. |
| `name` | string | save/delete | Short snake_case identifier for the memory. |
| `content` | string | save | Memory content to store. |
| `description` | string | no | Short description for relevance matching (recommended for `save`). |
| `type` | string | no | Memory type: `user`, `project`, `feedback`, or `reference`. Default: `project`. |
| `scope` | string | no | Memory scope: `global`, `workstream`, or `user`. Default: `global`. |
| `query` | string | search | Search query for finding memories. |
| `limit` | integer | no | Max results for `search` or `list`. Default: 20. |
- **What it does**: Stores a key-value pair in the SQLite memory database. Memories persist across sessions and are included in the system prompt on startup.
- **What it does**: Manages structured persistent memories in the database. Memories persist across sessions, have a type classification (user preferences, project knowledge, feedback, reference material) and a scope (global across all workstreams, private to a workstream, or following a user). Relevant memories are included in the system prompt on startup.
- **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only).
@@ -370,28 +374,14 @@ Save a persistent memory that persists across sessions.
### recall
Search memories and past conversations.
Search conversation history for past messages and tool results.
| Parameter | Type | Required | Description |
|-----------|---------|----------|-------------|
| `query` | string | no | Search term or phrase. Omit to list all memories. |
| `limit` | integer | no | Max conversation results to return (default 20). |
| `query` | string | yes | Search term or phrase to find in conversation history. |
| `limit` | integer | no | Max results to return (default 20). |
- **What it does**: With no query, lists all saved memories. With a query, searches both the memory store and conversation history using FTS5 full-text search.
- **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only).
---
### forget
Remove a persistent memory by key.
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------|
| `key` | string | yes | The memory key to remove (e.g. `user_name`). |
- **What it does**: Deletes the memory entry with the given key from the SQLite database.
- **What it does**: Searches conversation history across sessions using FTS5 full-text search. Returns matching messages, tool calls, and tool results with timestamps and workstream context.
- **Auto-approve**: Yes.
- **Agent availability**: Not available to sub-agents (top-level only).
@@ -514,9 +504,8 @@ data.get("mergedAt") is not None
| `web_search` | Info | No | Yes | Yes | `query` |
| `task` | Agent | No | No | No | `prompt` |
| `plan` | Agent | No | No | No | `prompt` |
| `remember` | Memory | Yes | No | No | `key` |
| `memory` | Memory | Yes | No | No | `name` |
| `recall` | Memory | Yes | No | No | `query` |
| `forget` | Memory | Yes | No | No | `key` |
| `notify` | Notify | Yes | Yes | Yes | `message` |
| `watch` | Monitor | No (create) | No | No | `command` |
| `read_resource`| MCP | No | Yes | Yes | `uri` |
@@ -573,7 +562,7 @@ CLI flags override the config file:
search stays off and all tools are sent to the model directly.
2. **Partitioning**: When active, tools are split into two sets:
- **Always-on** -- the 18 built-in tools (members of `BUILTIN_TOOL_NAMES`).
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
These are always visible to the model.
- **Deferred** -- all MCP tools. These are not sent in the tool list unless
the model searches for them.
@@ -616,7 +605,7 @@ MCP-compatible service.
3. **Schema conversion**: Each MCP tool's `inputSchema` is converted to OpenAI
function-calling format. The tool name is prefixed: `mcp__{server}__{tool}`.
4. **Merging**: MCP tools are appended after the 18 built-in tools via
4. **Merging**: MCP tools are appended after the 17 built-in tools via
`merge_mcp_tools()`. Built-in tools appear first, giving them natural LLM priority.
When dynamic tool search is active, MCP tools are deferred rather than directly
visible -- the model discovers them via search as needed (see
+71
View File
@@ -0,0 +1,71 @@
"""Tests for turnstone.core.bm25 — tokenizer and BM25 index."""
from turnstone.core.bm25 import BM25Index, _tokenize
class TestTokenize:
def test_simple_words(self):
assert _tokenize("hello world") == ["hello", "world"]
def test_underscores(self):
assert _tokenize("read_file") == ["read", "file"]
def test_hyphens(self):
assert _tokenize("web-search") == ["web", "search"]
def test_dots(self):
assert _tokenize("foo.bar.baz") == ["foo", "bar", "baz"]
def test_mixed_separators(self):
assert _tokenize("mcp__server__read_file") == ["mcp", "server", "read", "file"]
def test_empty_string(self):
assert _tokenize("") == []
def test_case_folding(self):
assert _tokenize("Hello World") == ["hello", "world"]
class TestBM25Index:
def test_search_returns_relevant(self):
docs = ["read a file from disk", "search for file in directory", "execute a bash command"]
index = BM25Index(docs)
results = index.search("file", k=2)
assert 0 in results
assert 1 in results
def test_search_empty_query(self):
docs = ["hello world"]
index = BM25Index(docs)
assert index.search("") == []
def test_search_no_match(self):
docs = ["hello world", "foo bar"]
index = BM25Index(docs)
assert index.search("zzzznotfound") == []
def test_search_respects_k(self):
docs = [f"document {i} with common word" for i in range(20)]
index = BM25Index(docs)
results = index.search("common", k=3)
assert len(results) <= 3
def test_empty_corpus(self):
index = BM25Index([])
assert index.search("anything") == []
def test_single_document(self):
index = BM25Index(["the only document about turnstone"])
results = index.search("turnstone")
assert results == [0]
def test_ordering_by_relevance(self):
docs = [
"unrelated content about cooking recipes",
"python programming with file operations",
"read file write file file operations disk io",
]
index = BM25Index(docs)
results = index.search("file operations", k=3)
# Doc 2 has more file/operations mentions, should rank higher
assert results[0] == 2
+4 -1
View File
@@ -16,7 +16,10 @@ class TestSchemaCreation:
engine = get_storage()._engine # noqa: SLF001
with engine.connect() as conn:
rows = conn.execute(
sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name='memories'")
sa.text(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name='structured_memories'"
)
).fetchall()
assert len(rows) == 1
rows = conn.execute(
+194
View File
@@ -0,0 +1,194 @@
"""Tests for turnstone.core.memory_relevance — scoring, formatting, context extraction."""
from turnstone.core.memory_relevance import (
build_memory_context,
extract_recent_context,
score_memories,
)
# ---------------------------------------------------------------------------
# score_memories
# ---------------------------------------------------------------------------
class TestScoreMemories:
def test_empty_memories(self):
assert score_memories([], "query") == []
def test_empty_query_returns_recent(self):
mems = [
{"name": "a", "description": "", "content": "alpha"},
{"name": "b", "description": "", "content": "beta"},
{"name": "c", "description": "", "content": "gamma"},
]
result = score_memories(mems, "", k=2)
assert len(result) == 2
assert result[0]["name"] == "a"
def test_whitespace_query_returns_recent(self):
mems = [{"name": "a", "description": "", "content": "alpha"}]
assert score_memories(mems, " ", k=5) == mems
def test_relevance_ranking(self):
mems = [
{"name": "cooking", "description": "recipes", "content": "pasta sauce tomato"},
{"name": "python", "description": "programming", "content": "python file io disk"},
{
"name": "disk_io",
"description": "file operations",
"content": "read write file disk",
},
]
result = score_memories(mems, "file disk", k=2)
names = [m["name"] for m in result]
assert "disk_io" in names
assert "python" in names
def test_k_limits_results(self):
mems = [{"name": f"m{i}", "description": "", "content": f"word{i}"} for i in range(10)]
result = score_memories(mems, "word0 word1 word2", k=2)
assert len(result) <= 2
def test_no_match_returns_empty(self):
mems = [{"name": "a", "description": "", "content": "hello world"}]
result = score_memories(mems, "zzzznotfound")
assert result == []
def test_uses_name_for_scoring(self):
mems = [
{"name": "database_config", "description": "", "content": "host=localhost"},
{"name": "unrelated", "description": "", "content": "nothing here"},
]
result = score_memories(mems, "database", k=1)
assert len(result) == 1
assert result[0]["name"] == "database_config"
def test_uses_description_for_scoring(self):
mems = [
{"name": "x", "description": "postgresql connection settings", "content": "host=db"},
{"name": "y", "description": "unrelated", "content": "nothing"},
]
result = score_memories(mems, "postgresql", k=1)
assert result[0]["name"] == "x"
# ---------------------------------------------------------------------------
# build_memory_context
# ---------------------------------------------------------------------------
class TestBuildMemoryContext:
def test_empty_memories(self):
assert build_memory_context([]) == ""
def test_single_memory(self):
mems = [{"name": "test", "type": "project", "scope": "global", "content": "hello"}]
ctx = build_memory_context(mems)
assert "<memories>" in ctx
assert "</memories>" in ctx
assert 'name="test"' in ctx
assert "hello" in ctx
def test_html_escaping(self):
mems = [
{
"name": "a<b",
"type": "project",
"scope": "global",
"content": "x & y",
"description": 'say "hi"',
}
]
ctx = build_memory_context(mems)
assert "&lt;" in ctx
assert "&amp;" in ctx
assert "&quot;" in ctx
def test_truncates_long_content(self):
mems = [
{
"name": "long",
"type": "project",
"scope": "global",
"content": "x" * 600,
}
]
ctx = build_memory_context(mems)
assert "..." in ctx
# Content should be truncated to 500 chars + "..."
assert "x" * 501 not in ctx
def test_description_attribute(self):
mems = [
{
"name": "test",
"type": "project",
"scope": "global",
"content": "data",
"description": "some desc",
}
]
ctx = build_memory_context(mems)
assert 'description="some desc"' in ctx
def test_no_description_attribute_when_empty(self):
mems = [{"name": "test", "type": "project", "scope": "global", "content": "data"}]
ctx = build_memory_context(mems)
assert "description=" not in ctx
# ---------------------------------------------------------------------------
# extract_recent_context
# ---------------------------------------------------------------------------
class TestExtractRecentContext:
def test_extracts_user_messages(self):
msgs = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
{"role": "user", "content": "world"},
]
ctx = extract_recent_context(msgs, max_messages=2)
assert "world" in ctx
assert "hello" in ctx
def test_skips_non_user(self):
msgs = [
{"role": "assistant", "content": "ignored"},
{"role": "user", "content": "included"},
]
ctx = extract_recent_context(msgs, max_messages=5)
assert "included" in ctx
assert "ignored" not in ctx
def test_respects_max_messages(self):
msgs = [
{"role": "user", "content": "first"},
{"role": "user", "content": "second"},
{"role": "user", "content": "third"},
]
ctx = extract_recent_context(msgs, max_messages=1)
assert "third" in ctx
assert "first" not in ctx
def test_handles_list_content(self):
msgs = [
{
"role": "user",
"content": [
{"type": "text", "text": "multi-part"},
{"type": "image_url", "image_url": {"url": "http://example.com"}},
],
}
]
ctx = extract_recent_context(msgs, max_messages=1)
assert "multi-part" in ctx
def test_handles_string_parts_in_list(self):
msgs = [{"role": "user", "content": ["plain string part"]}]
ctx = extract_recent_context(msgs, max_messages=1)
assert "plain string part" in ctx
def test_empty_messages(self):
assert extract_recent_context([]) == ""
+160
View File
@@ -0,0 +1,160 @@
"""Tests for turnstone.core.metacognition — detection, nudging, formatting."""
from turnstone.core.metacognition import (
NUDGE_COMPLETION,
NUDGE_CORRECTION,
NUDGE_DENIAL,
NUDGE_RESUME,
NUDGE_START,
detect_completion,
detect_correction,
format_nudge,
should_nudge,
)
class TestDetectCorrection:
def test_no_comma(self):
assert detect_correction("no, that's wrong") is True
def test_no_period(self):
assert detect_correction("no. do it differently") is True
def test_no_space(self):
assert detect_correction("no I meant the other one") is True
def test_dont(self):
assert detect_correction("don't use tabs") is True
def test_stop(self):
assert detect_correction("stop adding comments") is True
def test_actually(self):
assert detect_correction("actually, use pytest instead") is True
def test_instead(self):
assert detect_correction("instead, try this approach") is True
def test_wrong(self):
assert detect_correction("wrong, the port is 8080") is True
def test_i_said(self):
assert detect_correction("I said use snake_case") is True
def test_i_meant(self):
assert detect_correction("I meant the other file") is True
def test_please_dont(self):
assert detect_correction("please don't mock the database") is True
def test_negative_notice(self):
assert detect_correction("I noticed the test passes") is False
def test_negative_nobody(self):
assert detect_correction("nobody knows the answer") is False
def test_negative_innovation(self):
assert detect_correction("innovation in AI is exciting") is False
def test_negative_normal(self):
assert detect_correction("can you refactor this function?") is False
def test_negative_empty(self):
assert detect_correction("") is False
def test_negative_note(self):
assert detect_correction("note that this requires Python 3.11") is False
def test_negative_nonstop(self):
assert detect_correction("nonstop improvements to the codebase") is False
class TestDetectCompletion:
def test_thanks(self):
assert detect_completion("thanks, that's perfect") is True
def test_thats_all(self):
assert detect_completion("that's all for now") is True
def test_looks_good(self):
assert detect_completion("looks good to me") is True
def test_perfect(self):
assert detect_completion("perfect") is True
def test_lgtm(self):
assert detect_completion("lgtm") is True
def test_done(self):
assert detect_completion("done") is True
def test_negative_normal(self):
assert detect_completion("can you add error handling?") is False
def test_negative_empty(self):
assert detect_completion("") is False
class TestShouldNudge:
def test_basic_fires(self):
state: dict[str, float] = {}
assert should_nudge("correction", state, message_count=3, memory_count=0) is True
def test_cooldown(self):
state: dict[str, float] = {}
should_nudge("correction", state, message_count=3, memory_count=0)
assert should_nudge("correction", state, message_count=3, memory_count=0) is False
def test_different_types_independent(self):
state: dict[str, float] = {}
should_nudge("correction", state, message_count=3, memory_count=0)
assert should_nudge("denial", state, message_count=3, memory_count=0) is True
def test_no_nudge_first_message(self):
state: dict[str, float] = {}
assert should_nudge("correction", state, message_count=1, memory_count=0) is False
def test_resume_requires_memories(self):
state: dict[str, float] = {}
assert should_nudge("resume", state, message_count=5, memory_count=0) is False
assert should_nudge("resume", state, message_count=5, memory_count=3) is True
def test_resume_allowed_on_first_message(self):
state: dict[str, float] = {}
assert should_nudge("resume", state, message_count=1, memory_count=3) is True
def test_start_fires_on_first_message_with_memories(self):
state: dict[str, float] = {}
assert should_nudge("start", state, message_count=1, memory_count=3) is True
def test_start_requires_memories(self):
state: dict[str, float] = {}
assert should_nudge("start", state, message_count=1, memory_count=0) is False
def test_start_only_on_first_message(self):
state: dict[str, float] = {}
assert should_nudge("start", state, message_count=2, memory_count=3) is False
def test_invalid_type(self):
state: dict[str, float] = {}
assert should_nudge("invalid", state, message_count=3, memory_count=0) is False
class TestFormatNudge:
def test_correction(self):
assert format_nudge("correction") == NUDGE_CORRECTION
def test_denial(self):
assert format_nudge("denial") == NUDGE_DENIAL
def test_resume(self):
assert format_nudge("resume") == NUDGE_RESUME
def test_completion(self):
assert format_nudge("completion") == NUDGE_COMPLETION
def test_start(self):
assert format_nudge("start") == NUDGE_START
def test_invalid(self):
assert format_nudge("invalid") == ""
-40
View File
@@ -215,46 +215,6 @@ class TestWorkstreamMetadata:
assert backend.get_workstream_display_name("s1") == "Alias"
# -- Key-value store -----------------------------------------------------------
class TestKVStore:
def test_set_and_get(self, backend):
assert backend.kv_set("key1", "value1") is None # no previous
assert backend.kv_get("key1") == "value1"
def test_set_returns_old_value(self, backend):
backend.kv_set("key1", "v1")
old = backend.kv_set("key1", "v2")
assert old == "v1"
assert backend.kv_get("key1") == "v2"
def test_delete(self, backend):
backend.kv_set("key1", "v1")
assert backend.kv_delete("key1")
assert backend.kv_get("key1") is None
def test_delete_nonexistent(self, backend):
assert not backend.kv_delete("nope")
def test_list(self, backend):
backend.kv_set("b", "2")
backend.kv_set("a", "1")
assert backend.kv_list() == [("a", "1"), ("b", "2")]
def test_search(self, backend):
backend.kv_set("project_name", "turnstone")
backend.kv_set("version", "0.3")
results = backend.kv_search("turnstone")
assert len(results) == 1
assert results[0] == ("project_name", "turnstone")
def test_search_empty_lists_all(self, backend):
backend.kv_set("a", "1")
backend.kv_set("b", "2")
assert len(backend.kv_search("")) == 2
# -- Conversation search -------------------------------------------------------
+82
View File
@@ -0,0 +1,82 @@
"""Tests for turnstone.core.memory — structured memory facade functions."""
from turnstone.core.memory import (
count_structured_memories,
delete_structured_memory,
list_structured_memories,
normalize_key,
save_structured_memory,
search_structured_memories,
)
class TestSaveStructuredMemory:
def test_save_new(self, tmp_db):
mid, old = save_structured_memory("test_key", "hello world")
assert mid != ""
assert old is None
def test_save_upsert(self, tmp_db):
save_structured_memory("test_key", "first")
mid, old = save_structured_memory("test_key", "second")
assert old == "first"
assert mid != ""
def test_save_normalizes_key(self, tmp_db):
save_structured_memory("My-Key", "value")
mems = list_structured_memories()
assert any(m["name"] == "my_key" for m in mems)
def test_save_with_type_and_scope(self, tmp_db):
save_structured_memory("k", "v", mem_type="user", scope="workstream", scope_id="ws1")
mems = list_structured_memories(scope="workstream", scope_id="ws1")
assert len(mems) == 1
assert mems[0]["type"] == "user"
class TestDeleteStructuredMemory:
def test_delete_existing(self, tmp_db):
save_structured_memory("mykey", "val")
assert delete_structured_memory("mykey")
def test_delete_nonexistent(self, tmp_db):
assert not delete_structured_memory("nope")
def test_delete_normalizes_key(self, tmp_db):
save_structured_memory("my_key", "val")
assert delete_structured_memory("My-Key")
class TestListStructuredMemories:
def test_list_empty(self, tmp_db):
assert list_structured_memories() == []
def test_list_returns_saved(self, tmp_db):
save_structured_memory("a", "alpha")
save_structured_memory("b", "beta")
mems = list_structured_memories()
assert len(mems) == 2
class TestSearchStructuredMemories:
def test_search_finds_match(self, tmp_db):
save_structured_memory("db_host", "localhost", description="database hostname")
save_structured_memory("api_url", "http://example.com")
results = search_structured_memories("database")
assert len(results) >= 1
assert any(r["name"] == "db_host" for r in results)
class TestCountStructuredMemories:
def test_count_zero(self, tmp_db):
assert count_structured_memories() == 0
def test_count_after_save(self, tmp_db):
save_structured_memory("a", "1")
save_structured_memory("b", "2")
assert count_structured_memories() == 2
class TestNormalizeKey:
def test_basic(self):
assert normalize_key("My-Key Name") == "my_key_name"
+137
View File
@@ -0,0 +1,137 @@
"""Tests for structured memory storage backend operations."""
import pytest
from turnstone.core.storage._sqlite import SQLiteBackend
@pytest.fixture
def backend(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
class TestCreateAndGet:
def test_create_and_get_by_id(self, backend):
backend.create_structured_memory("m1", "test_key", "desc", "project", "global", "", "data")
mem = backend.get_structured_memory("m1")
assert mem is not None
assert mem["name"] == "test_key"
assert mem["content"] == "data"
assert mem["type"] == "project"
def test_get_nonexistent(self, backend):
assert backend.get_structured_memory("nope") is None
def test_get_by_name(self, backend):
backend.create_structured_memory("m1", "mykey", "d", "project", "global", "", "val")
mem = backend.get_structured_memory_by_name("mykey", "global", "")
assert mem is not None
assert mem["memory_id"] == "m1"
def test_get_by_name_scoped(self, backend):
backend.create_structured_memory("m1", "key", "d", "project", "global", "", "g")
backend.create_structured_memory("m2", "key", "d", "project", "workstream", "ws1", "w")
g = backend.get_structured_memory_by_name("key", "global", "")
w = backend.get_structured_memory_by_name("key", "workstream", "ws1")
assert g["content"] == "g"
assert w["content"] == "w"
class TestUpdate:
def test_update_content(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "old")
assert backend.update_structured_memory("m1", content="new")
mem = backend.get_structured_memory("m1")
assert mem["content"] == "new"
def test_update_nonexistent(self, backend):
assert not backend.update_structured_memory("nope", content="x")
def test_update_no_fields(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "data")
assert not backend.update_structured_memory("m1", bogus="val")
def test_update_bumps_timestamp(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "data")
old = backend.get_structured_memory("m1")["updated"]
import time
time.sleep(0.01)
backend.update_structured_memory("m1", content="new")
new = backend.get_structured_memory("m1")["updated"]
assert new >= old
class TestDelete:
def test_delete_existing(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "global", "", "data")
assert backend.delete_structured_memory("k", "global", "")
assert backend.get_structured_memory("m1") is None
def test_delete_nonexistent(self, backend):
assert not backend.delete_structured_memory("nope", "global", "")
def test_delete_scoped(self, backend):
backend.create_structured_memory("m1", "k", "d", "project", "workstream", "ws1", "data")
assert not backend.delete_structured_memory("k", "global", "")
assert backend.delete_structured_memory("k", "workstream", "ws1")
class TestList:
def test_list_all(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
mems = backend.list_structured_memories()
assert len(mems) == 2
def test_list_by_type(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "user", "global", "", "2")
mems = backend.list_structured_memories(mem_type="user")
assert len(mems) == 1
assert mems[0]["name"] == "b"
def test_list_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "workstream", "ws1", "2")
mems = backend.list_structured_memories(scope="workstream")
assert len(mems) == 1
def test_list_respects_limit(self, backend):
for i in range(10):
backend.create_structured_memory(f"m{i}", f"k{i}", "", "project", "global", "", f"{i}")
mems = backend.list_structured_memories(limit=3)
assert len(mems) == 3
class TestSearch:
def test_search_by_name(self, backend):
backend.create_structured_memory("m1", "database_config", "", "project", "global", "", "pg")
backend.create_structured_memory("m2", "api_key", "", "project", "global", "", "secret")
results = backend.search_structured_memories("database")
assert len(results) == 1
assert results[0]["name"] == "database_config"
def test_search_by_content(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "postgresql host")
results = backend.search_structured_memories("postgresql")
assert len(results) == 1
def test_search_empty_lists_all(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "global", "", "2")
results = backend.search_structured_memories("")
assert len(results) == 2
class TestCount:
def test_count_all(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "global", "", "2")
assert backend.count_structured_memories() == 2
def test_count_by_scope(self, backend):
backend.create_structured_memory("m1", "a", "", "project", "global", "", "1")
backend.create_structured_memory("m2", "b", "", "project", "workstream", "ws1", "2")
assert backend.count_structured_memories(scope="global") == 1
assert backend.count_structured_memories(scope="workstream") == 1
+2 -3
View File
@@ -72,7 +72,7 @@ class TestToolsMetadata:
"""Validate the metadata extracted from JSON files."""
def test_tool_count(self):
assert len(TOOLS) == 18
assert len(TOOLS) == 17
def test_agent_tools_count(self):
assert len(AGENT_TOOLS) == 9
@@ -106,9 +106,8 @@ class TestToolsMetadata:
"web_search": "query",
"task": "prompt",
"create_plan": "goal",
"remember": "key",
"memory": "name",
"recall": "query",
"forget": "key",
"notify": "message",
"watch": "command",
"read_resource": "uri",
+1 -1
View File
@@ -7,7 +7,7 @@ All functionality has been moved to submodules:
- turnstone.core.sandbox: validate_math_code, execute_math_sandboxed
- turnstone.core.safety: is_command_blocked, sanitize_command
- turnstone.core.web: strip_html, check_ssrf
- turnstone.core.memory: open_db, load_memories, save_message, etc.
- turnstone.core.memory: save_message, structured memory facade, etc.
- turnstone.ui.colors: ANSI constants and helpers
- turnstone.ui.markdown: MarkdownRenderer
- turnstone.ui.spinner: Spinner
+62
View File
@@ -0,0 +1,62 @@
"""BM25 index — lightweight, pure-Python, zero external deps.
Extracted from tool_search.py for reuse by memory relevance scoring.
"""
from __future__ import annotations
import math
import re
from collections import Counter
_SPLIT_RE = re.compile(r"[_\-./\s]+")
def _tokenize(text: str) -> list[str]:
"""Split text on whitespace, underscores, hyphens, dots."""
return [t.lower() for t in _SPLIT_RE.split(text) if t]
class BM25Index:
"""Okapi BM25 ranking index over short text documents."""
def __init__(self, documents: list[str], *, k1: float = 1.5, b: float = 0.75) -> None:
self.k1 = k1
self.b = b
self._docs = documents
self._doc_tokens: list[list[str]] = [_tokenize(d) for d in documents]
self._doc_lens = [len(t) for t in self._doc_tokens]
self._avgdl = sum(self._doc_lens) / max(len(self._doc_lens), 1)
self._n = len(documents)
# Document frequency per term
self._df: Counter[str] = Counter()
for tokens in self._doc_tokens:
for term in set(tokens):
self._df[term] += 1
def search(self, query: str, k: int = 5) -> list[int]:
"""Return indices of top-k documents sorted by descending BM25 score."""
q_tokens = _tokenize(query)
if not q_tokens:
return []
scores: list[tuple[float, int]] = []
for idx, doc_tokens in enumerate(self._doc_tokens):
score = self._score(q_tokens, doc_tokens, self._doc_lens[idx])
if score > 0:
scores.append((score, idx))
scores.sort(key=lambda x: (-x[0], x[1]))
return [idx for _, idx in scores[:k]]
def _score(self, q_tokens: list[str], doc_tokens: list[str], dl: int) -> float:
tf_map: Counter[str] = Counter(doc_tokens)
score = 0.0
for term in q_tokens:
if term not in tf_map:
continue
tf = tf_map[term]
df = self._df.get(term, 0)
idf = math.log((self._n - df + 0.5) / (df + 0.5) + 1.0)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avgdl)
score += idf * numerator / denominator
return score
+95 -35
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import contextlib
from typing import TYPE_CHECKING, Any
import sqlalchemy as sa
from turnstone.core.storage import get_storage
if TYPE_CHECKING:
@@ -220,41 +222,6 @@ def update_workstream_title(ws_id: str, title: str) -> None:
get_storage().update_workstream_title(ws_id, title)
# -- Key-value store (memories) ------------------------------------------------
def save_memory(key: str, value: str) -> str | None:
"""Save a memory. Returns the previous value if it existed."""
try:
return get_storage().kv_set(key, value)
except Exception:
return None
def delete_memory(key: str) -> bool:
"""Delete a memory by key. Returns True if the key existed."""
try:
return get_storage().kv_delete(key)
except Exception:
return False
def load_memories() -> list[tuple[str, str]]:
"""Return all (key, value) memory pairs sorted by key."""
try:
return get_storage().kv_list()
except Exception:
return []
def search_memories(query: str) -> list[tuple[str, str]]:
"""Search memories by query. Returns matching (key, value) pairs."""
try:
return get_storage().kv_search(query)
except Exception:
return []
# -- Conversation search -------------------------------------------------------
@@ -272,3 +239,96 @@ def search_history_recent(limit: int = 20) -> list[Any]:
return get_storage().search_history_recent(limit)
except Exception:
return []
# -- Structured memories -------------------------------------------------------
def save_structured_memory(
name: str,
content: str,
description: str = "",
mem_type: str = "project",
scope: str = "global",
scope_id: str = "",
) -> tuple[str, str | None]:
"""Save a structured memory (upsert by name+scope+scope_id).
Returns (memory_id, old_content_or_None). Uses create-first to
avoid TOCTOU races under concurrent access.
"""
import uuid
name = normalize_key(name)
try:
storage = get_storage()
# Try create first — if it hits the unique constraint, fall back to update
memory_id = str(uuid.uuid4())
try:
storage.create_structured_memory(
memory_id, name, description, mem_type, scope, scope_id, content
)
return memory_id, None
except sa.exc.IntegrityError:
# Unique constraint violation — row already exists, update it
existing = storage.get_structured_memory_by_name(name, scope, scope_id)
if existing:
old_content = existing["content"]
updates: dict[str, str] = {"content": content}
if description:
updates["description"] = description
if mem_type != "project":
updates["type"] = mem_type
storage.update_structured_memory(existing["memory_id"], **updates)
return existing["memory_id"], old_content
return "", None
except Exception:
return "", None
def delete_structured_memory(name: str, scope: str = "global", scope_id: str = "") -> bool:
"""Delete a structured memory by name+scope. Returns True if existed."""
name = normalize_key(name)
try:
return get_storage().delete_structured_memory(name, scope, scope_id)
except Exception:
return False
def list_structured_memories(
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""List structured memories with optional filters."""
try:
return get_storage().list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
except Exception:
return []
def search_structured_memories(
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""Search structured memories by query."""
try:
return get_storage().search_structured_memories(
query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
except Exception:
return []
def count_structured_memories(scope: str = "", scope_id: str = "") -> int:
"""Count structured memories with optional scope filter."""
try:
return get_storage().count_structured_memories(scope=scope, scope_id=scope_id)
except Exception:
return 0
+85
View File
@@ -0,0 +1,85 @@
"""BM25-based memory relevance scoring and system message formatting."""
from __future__ import annotations
from html import escape as _html_escape
from typing import Any
from turnstone.core.bm25 import BM25Index
def score_memories(
memories: list[dict[str, str]],
query: str,
k: int = 5,
) -> list[dict[str, str]]:
"""Return the top-k memories most relevant to *query*.
Builds a BM25 index over ``name + description + content prefix``
for each memory and returns matches sorted by relevance. If *query*
is empty, returns the most recent *k* memories (they are already
ordered by ``updated DESC`` from storage).
"""
if not memories:
return []
if not query or not query.strip():
return memories[:k]
documents = [
f"{m.get('name', '')} {m.get('description', '')} {m.get('content', '')[:200]}"
for m in memories
]
index = BM25Index(documents)
top_indices = index.search(query, k)
return [memories[i] for i in top_indices]
def build_memory_context(memories: list[dict[str, str]]) -> str:
"""Format selected memories as an XML block for system message injection.
Produces a compact ``<memories>`` section matching the style used
for MCP resources (``<mcp-resources>``).
"""
if not memories:
return ""
lines = ["<memories>"]
for m in memories:
name = _html_escape(m.get("name", ""))
mem_type = _html_escape(m.get("type", "project"))
scope = _html_escape(m.get("scope", "global"))
desc = m.get("description", "")
content = m.get("content", "")
# Truncate content to avoid bloating system message
if len(content) > 500:
content = content[:500] + "..."
desc_attr = f' description="{_html_escape(desc)}"' if desc else ""
lines.append(
f' <memory name="{name}" type="{mem_type}" scope="{scope}"{desc_attr}>'
f"{_html_escape(content)}</memory>"
)
lines.append("</memories>")
return "\n".join(lines)
def extract_recent_context(messages: list[dict[str, Any]], max_messages: int = 3) -> str:
"""Extract text from the last N user messages for relevance scoring.
Handles both string and list content formats.
"""
user_texts: list[str] = []
for msg in reversed(messages):
if msg.get("role") != "user":
continue
content = msg.get("content", "")
if isinstance(content, str):
user_texts.append(content)
elif isinstance(content, list):
# Multi-part content (text + images)
for part in content:
if isinstance(part, dict) and part.get("type") == "text":
user_texts.append(part.get("text", ""))
elif isinstance(part, str):
user_texts.append(part)
if len(user_texts) >= max_messages:
break
return " ".join(user_texts)
+130
View File
@@ -0,0 +1,130 @@
"""Metacognitive prompting — situational nudges for proactive memory use."""
from __future__ import annotations
import re
import time
_COOLDOWN_SECS = 300 # 5 minutes between nudges of the same type
# ---------------------------------------------------------------------------
# Nudge messages (brief, model-facing hints)
# ---------------------------------------------------------------------------
NUDGE_CORRECTION = (
"Note: The user's message may contain a correction or preference. "
"Pay close attention — if they explain what went wrong or how they'd "
"prefer you to work, consider saving that as a feedback memory "
"(memory action='save', type='feedback') so you don't repeat this."
)
NUDGE_DENIAL = (
"Note: The user just rejected a tool action. Their feedback may "
"explain why — pay attention to whether this reflects a persistent "
"preference (e.g. 'never use force-push', 'don't modify that file'). "
"If so, save it as a feedback memory for future sessions."
)
NUDGE_RESUME = (
"This workstream has prior conversation history. Before proceeding, "
"use memory(action='search') to check for relevant context — there "
"may be saved preferences, project notes, or prior decisions that "
"apply to this work."
)
NUDGE_COMPLETION = (
"The task may be wrapping up. Consider whether there are learnings, "
"decisions, or user preferences from this session worth persisting "
"as memories (memory action='save') so future sessions can benefit."
)
NUDGE_START = (
"You have saved memories from prior sessions that may be relevant. "
"Consider using memory(action='search') with keywords from the "
"user's request to find applicable context, preferences, or guidance."
)
_NUDGE_MAP: dict[str, str] = {
"correction": NUDGE_CORRECTION,
"denial": NUDGE_DENIAL,
"resume": NUDGE_RESUME,
"completion": NUDGE_COMPLETION,
"start": NUDGE_START,
}
# ---------------------------------------------------------------------------
# Detection heuristics
# ---------------------------------------------------------------------------
_CORRECTION_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"(?i)^no[,.\s]"),
re.compile(r"(?i)\bdon'?t\b"),
re.compile(r"(?i)^stop\b"),
re.compile(r"(?i)^actually[,\s]"),
re.compile(r"(?i)^instead[,\s]"),
re.compile(r"(?i)\bnot like that\b"),
re.compile(r"(?i)^wrong\b"),
re.compile(r"(?i)\bthat'?s not\b"),
re.compile(r"(?i)^I said\b"),
re.compile(r"(?i)^I meant\b"),
re.compile(r"(?i)\bnever\b.*\balways\b"),
re.compile(r"(?i)^please don'?t\b"),
]
_COMPLETION_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"(?i)^thanks\b"),
re.compile(r"(?i)\bthat'?s all\b"),
re.compile(r"(?i)\blooks good\b"),
re.compile(r"(?i)^perfect\b"),
re.compile(r"(?i)^great job\b"),
re.compile(r"(?i)\bthat works\b"),
re.compile(r"(?i)^done\b"),
re.compile(r"(?i)^lgtm\b"),
]
def detect_correction(message: str) -> bool:
"""Return True if the message looks like a user correction."""
if not message:
return False
return any(p.search(message) for p in _CORRECTION_PATTERNS)
def detect_completion(message: str) -> bool:
"""Return True if the message signals session completion."""
if not message:
return False
return any(p.search(message) for p in _COMPLETION_PATTERNS)
def should_nudge(
nudge_type: str,
state: dict[str, float],
*,
message_count: int = 0,
memory_count: int = 0,
) -> bool:
"""Check whether a nudge should fire, respecting cooldowns and context."""
if nudge_type not in _NUDGE_MAP:
return False
# Don't nudge on the very first message (except resume/start)
if message_count <= 1 and nudge_type not in ("resume", "start"):
return False
# Start nudge only on first message
if nudge_type == "start" and message_count != 1:
return False
# Resume/start nudge only if there are memories to recall
if nudge_type in ("resume", "start") and memory_count == 0:
return False
# Rate limit: one nudge per type per cooldown window
now = time.monotonic()
last = state.get(nudge_type)
if last is not None and now - last < _COOLDOWN_SECS:
return False
state[nudge_type] = now
return True
def format_nudge(nudge_type: str) -> str:
"""Return the nudge text for the given type."""
return _NUDGE_MAP.get(nudge_type, "")
+376 -103
View File
@@ -33,26 +33,38 @@ from turnstone.core.config import get_tavily_key
from turnstone.core.edit import find_occurrences, pick_nearest
from turnstone.core.log import get_logger
from turnstone.core.memory import (
delete_memory,
count_structured_memories,
delete_structured_memory,
delete_workstream,
get_prompt_template_by_name,
get_workstream_display_name,
list_default_templates,
list_structured_memories,
list_workstreams_with_history,
load_memories,
load_messages,
load_workstream_config,
normalize_key,
resolve_workstream,
save_memory,
save_message,
save_structured_memory,
save_workstream_config,
search_history,
search_history_recent,
search_memories,
search_structured_memories,
set_workstream_alias,
update_workstream_title,
)
from turnstone.core.memory_relevance import (
build_memory_context,
extract_recent_context,
score_memories,
)
from turnstone.core.metacognition import (
detect_completion,
detect_correction,
format_nudge,
should_nudge,
)
from turnstone.core.providers import create_provider
from turnstone.core.safety import is_command_blocked, sanitize_command
from turnstone.core.sandbox import execute_math_sandboxed
@@ -110,6 +122,7 @@ _IMAGE_SIZE_CAP: int = 4 * 1024 * 1024
# Upper bound on total prompt template content injected into system messages
_MAX_TEMPLATE_CONTENT: int = 32768
_MAX_MEMORY_CONTENT: int = 32768
_TEMPLATE_VAR_RE = re.compile(r"\{\{(\w+)\}\}")
@@ -222,6 +235,7 @@ class ChatSession:
tool_search_max_results: int = 5,
template: str | None = None,
judge_config: JudgeConfig | None = None,
user_id: str = "",
):
self.client = client
self.model = model
@@ -255,6 +269,7 @@ class ChatSession:
self.debug = False
self.auto_approve = False
self._node_id = node_id
self._user_id = user_id
self._ws_id = ws_id or uuid.uuid4().hex
self._title_generated = False
self._read_files: set[str] = set()
@@ -277,6 +292,9 @@ class ChatSession:
self._watch_runner: Any = None # WatchRunner | None
self._watch_pending: queue.Queue[dict[str, Any]] = queue.Queue()
self._watch_dispatch_depth = 0
# Metacognitive nudges: ephemeral prompts for proactive memory use
self._metacog_state: dict[str, float] = {}
self._pending_nudge: str | None = None
# Cooperative cancellation: set from outside to stop generation
self._cancel_event = threading.Event()
self._cancelled_partial_msg: dict[str, Any] | None = None
@@ -639,7 +657,14 @@ class ChatSession:
self._template_name = None
if "notify_on_complete" in config:
self._notify_on_complete = config["notify_on_complete"]
self._init_system_messages()
if should_nudge(
"resume",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
):
self._pending_nudge = format_nudge("resume")
self._init_system_messages()
return True
def _init_system_messages(self) -> None:
@@ -766,13 +791,22 @@ class ChatSession:
if self.instructions:
dev_parts.append("")
dev_parts.append(self.instructions)
memories = load_memories()
if memories:
visible_mems = self._get_visible_memories(limit=50)
if visible_mems:
context = extract_recent_context(self.messages)
relevant = score_memories(visible_mems, context, k=5)
if relevant:
dev_parts.append("")
dev_parts.append(build_memory_context(relevant))
dev_parts.append("")
dev_parts.append(
f"REMINDER: You currently have {len(memories)} memories stored. "
"Use recall to see them."
f"You have {len(visible_mems)} memories in scope. "
"Use memory(action='search') or memory(action='list') for more."
)
if self._pending_nudge:
dev_parts.append("")
dev_parts.append(self._pending_nudge)
self._pending_nudge = None
new_system_messages.append({"role": "system", "content": "\n".join(dev_parts)})
# Atomic swap — readers see either old or new, never partial
self.system_messages = new_system_messages
@@ -957,6 +991,12 @@ class ChatSession:
self._msg_tokens.append(max(1, int(len(user_input) / self._chars_per_token)))
save_message(self._ws_id, "user", user_input)
# Metacognitive nudge: check for correction/completion signals
nudge = self._check_metacognitive_nudge(user_input)
if nudge:
self._pending_nudge = nudge
self._init_system_messages()
try:
while True:
self._check_cancelled()
@@ -996,8 +1036,7 @@ class ChatSession:
filtered_tc = [
call
for call in tc
if call.get("function", {}).get("name", "")
not in ("remember", "forget", "recall")
if call.get("function", {}).get("name", "") not in ("memory", "recall")
]
if filtered_tc:
tool_calls_json = json.dumps(filtered_tc)
@@ -1066,8 +1105,7 @@ class ChatSession:
# Log tool result (skip memory tools to avoid noise)
_tname = _tc_names.get(tc_id, "")
if _tname not in (
"remember",
"forget",
"memory",
"recall",
):
# For image content, store text description only
@@ -1626,7 +1664,11 @@ class ChatSession:
" - **## Open tasks**: What the user asked for that is not yet done, "
"with enough context to continue.\n"
" - **## User preferences**: Workflow preferences, constraints, or "
"instructions the user stated.\n\n"
"instructions the user stated.\n"
" - **## Memories to save**: Corrections, preferences, or learnings "
"the user expressed that should be persisted across sessions. "
"Format each as: `name: description — content`. "
"Only include items the user explicitly stated, not inferences.\n\n"
"2. **Density rules:**\n"
" - Every token should carry information.\n"
" - Preserve exact paths, identifiers, and numbers — never paraphrase these.\n"
@@ -1821,6 +1863,14 @@ class ChatSession:
f"Denied by user: {user_feedback}" if user_feedback else "Denied by user"
)
user_feedback = None # feedback is in the denial_msg
if should_nudge(
"denial",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
):
self._pending_nudge = format_nudge("denial")
self._init_system_messages()
# Phase 3: execute (check cancellation before starting)
self._check_cancelled()
@@ -1972,9 +2022,8 @@ class ChatSession:
"tool_search": self._prepare_tool_search,
"task": self._prepare_task,
"create_plan": self._prepare_plan,
"remember": self._prepare_remember,
"memory": self._prepare_memory,
"recall": self._prepare_recall,
"forget": self._prepare_forget,
"notify": self._prepare_notify,
"watch": self._prepare_watch,
"read_resource": self._prepare_read_resource,
@@ -2545,55 +2594,232 @@ class ChatSession:
"prompt": goal,
}
def _prepare_remember(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a remember (save memory) action."""
key = normalize_key((args.get("key") or "").strip())
value = (args.get("value") or "").strip()
if not key or not value:
return {
"call_id": call_id,
"func_name": "remember",
"header": "\u2717 remember: requires key and value",
"preview": "",
"needs_approval": False,
"error": "Error: both 'key' and 'value' are required",
}
return {
"call_id": call_id,
"func_name": "remember",
"header": f"\u2699 remember: {key}",
"preview": "",
"needs_approval": False,
"execute": self._exec_remember,
"key": key,
"value": value,
}
def _resolve_scope_id(self, scope: str) -> str:
"""Map a scope name to its scope_id."""
if scope == "workstream":
return self._ws_id
if scope == "user":
return self._user_id
return ""
def _prepare_forget(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a forget (delete memory) action."""
key = normalize_key((args.get("key") or "").strip())
if not key:
def _validate_scope(self, scope: str, call_id: str) -> dict[str, Any] | None:
"""Return an error dict if scope is invalid, None if OK."""
if scope == "user" and not self._user_id:
return {
"call_id": call_id,
"func_name": "forget",
"header": "\u2717 forget: empty key",
"func_name": "memory",
"header": "\u2717 memory: user scope requires authentication",
"preview": "",
"needs_approval": False,
"error": "Error: key is required",
"error": "Error: 'user' scope requires authenticated user identity",
}
return None
def _get_visible_memories(self, limit: int = 50) -> list[dict[str, str]]:
"""Return memories visible to this session (scope-filtered)."""
global_mems = list_structured_memories(scope="global", limit=limit)
ws_mems = list_structured_memories(scope="workstream", scope_id=self._ws_id, limit=limit)
user_mems: list[dict[str, str]] = []
if self._user_id:
user_mems = list_structured_memories(scope="user", scope_id=self._user_id, limit=limit)
combined = global_mems + ws_mems + user_mems
combined.sort(key=lambda m: m.get("updated", ""), reverse=True)
return combined[:limit]
def _visible_memory_count(self) -> int:
"""Count memories visible to this session (cheap — counts only)."""
n = count_structured_memories(scope="global")
n += count_structured_memories(scope="workstream", scope_id=self._ws_id)
if self._user_id:
n += count_structured_memories(scope="user", scope_id=self._user_id)
return n
def _check_metacognitive_nudge(self, user_message: str) -> str | None:
"""Check if a metacognitive nudge should be injected."""
mem_count = self._visible_memory_count()
msg_count = len(self.messages)
# First message in a new workstream — nudge to check existing memories
if should_nudge(
"start", self._metacog_state, message_count=msg_count, memory_count=mem_count
):
return format_nudge("start")
if detect_correction(user_message) and should_nudge(
"correction", self._metacog_state, message_count=msg_count, memory_count=mem_count
):
return format_nudge("correction")
if detect_completion(user_message) and should_nudge(
"completion", self._metacog_state, message_count=msg_count, memory_count=mem_count
):
return format_nudge("completion")
return None
def _prepare_memory(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a memory tool action (save/search/delete/list)."""
action = (args.get("action") or "").strip().lower()
if action == "save":
name = (args.get("name") or args.get("key") or "").strip()
content = (args.get("content") or args.get("value") or "").strip()
name = normalize_key(name)
if not name or not content:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory save: requires name and content",
"preview": "",
"needs_approval": False,
"error": "Error: both 'name' and 'content' are required for save",
}
if len(content) > _MAX_MEMORY_CONTENT:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory save: content too large",
"preview": "",
"needs_approval": False,
"error": f"Error: content exceeds {_MAX_MEMORY_CONTENT} byte limit",
}
description = (args.get("description") or "").strip()
mem_type = (args.get("type") or "project").strip().lower()
if mem_type not in ("user", "project", "feedback", "reference"):
mem_type = "project"
scope = (args.get("scope") or "global").strip().lower()
if scope not in ("global", "workstream", "user"):
scope = "global"
scope_err = self._validate_scope(scope, call_id)
if scope_err:
return scope_err
scope_id = self._resolve_scope_id(scope)
return {
"call_id": call_id,
"func_name": "memory",
"header": f"\u2699 memory save: {name}",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "save",
"name": name,
"content": content,
"description": description,
"mem_type": mem_type,
"scope": scope,
"scope_id": scope_id,
}
if action == "delete":
name = normalize_key((args.get("name") or args.get("key") or "").strip())
if not name:
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2717 memory delete: empty name",
"preview": "",
"needs_approval": False,
"error": "Error: name is required for delete",
}
scope = (args.get("scope") or "global").strip().lower()
if scope not in ("global", "workstream", "user"):
scope = "global"
scope_err = self._validate_scope(scope, call_id)
if scope_err:
return scope_err
scope_id = self._resolve_scope_id(scope)
return {
"call_id": call_id,
"func_name": "memory",
"header": f"\u2699 memory delete: {name}",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "delete",
"name": name,
"scope": scope,
"scope_id": scope_id,
}
if action == "search":
query = (args.get("query") or "").strip()
mem_type = (args.get("type") or "").strip().lower()
if mem_type and mem_type not in ("user", "project", "feedback", "reference"):
mem_type = ""
scope = (args.get("scope") or "").strip().lower()
if scope and scope not in ("global", "workstream", "user"):
scope = ""
scope_id = self._resolve_scope_id(scope) if scope else ""
limit = args.get("limit", 20)
if isinstance(limit, str):
try:
limit = int(limit)
except ValueError:
limit = 20
return {
"call_id": call_id,
"func_name": "memory",
"header": f"\u2699 memory search{': ' + query[:80] if query else ''}",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "search",
"query": query,
"mem_type": mem_type,
"scope": scope,
"scope_id": scope_id,
"limit": max(1, min(limit, 50)),
}
if action == "list":
mem_type = (args.get("type") or "").strip().lower()
if mem_type and mem_type not in ("user", "project", "feedback", "reference"):
mem_type = ""
scope = (args.get("scope") or "").strip().lower()
if scope and scope not in ("global", "workstream", "user"):
scope = ""
scope_id = self._resolve_scope_id(scope) if scope else ""
limit = args.get("limit", 20)
if isinstance(limit, str):
try:
limit = int(limit)
except ValueError:
limit = 20
return {
"call_id": call_id,
"func_name": "memory",
"header": "\u2699 memory list",
"preview": "",
"needs_approval": False,
"execute": self._exec_memory,
"action": "list",
"mem_type": mem_type,
"scope": scope,
"scope_id": scope_id,
"limit": max(1, min(limit, 50)),
}
return {
"call_id": call_id,
"func_name": "forget",
"header": f"\u2699 forget: {key}",
"func_name": "memory",
"header": "\u2717 memory: invalid action",
"preview": "",
"needs_approval": False,
"execute": self._exec_forget,
"key": key,
"error": f"Error: action must be save/search/delete/list, got '{action}'",
}
def _prepare_recall(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
"""Prepare a recall action."""
"""Prepare a conversation history search."""
query = (args.get("query") or "").strip()
if not query:
return {
"call_id": call_id,
"func_name": "recall",
"header": "\u2717 recall: requires query",
"preview": "",
"needs_approval": False,
"error": "Error: query is required",
}
limit = args.get("limit", 20)
if isinstance(limit, str):
try:
@@ -2603,12 +2829,12 @@ class ChatSession:
return {
"call_id": call_id,
"func_name": "recall",
"header": f"\u2699 recall{': ' + query[:80] if query else ''}",
"header": f"\u2699 recall: {query[:80]}",
"preview": "",
"needs_approval": False,
"execute": self._exec_recall,
"query": query,
"limit": min(limit, 50),
"limit": max(1, min(limit, 50)),
}
# -- MCP tool prepare/execute ----------------------------------------------
@@ -3500,66 +3726,113 @@ class ChatSession:
return content
def _exec_remember(self, item: dict[str, Any]) -> tuple[str, str]:
"""Save a persistent memory."""
call_id, key, value = item["call_id"], item["key"], item["value"]
def _exec_memory(self, item: dict[str, Any]) -> tuple[str, str]:
"""Execute a memory tool action."""
call_id = item["call_id"]
action = item["action"]
try:
old_value = save_memory(key, value)
self._init_system_messages()
if old_value is not None:
msg = f"Updated memory: {key} = {value} (was: {old_value})"
else:
msg = f"Saved memory: {key} = {value}"
self.ui.on_tool_result(call_id, "remember", msg)
return call_id, msg
if action == "save":
memory_id, old = save_structured_memory(
item["name"],
item["content"],
description=item["description"],
mem_type=item["mem_type"],
scope=item["scope"],
scope_id=item["scope_id"],
)
if not memory_id:
msg = f"Error: failed to save memory '{item['name']}'"
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
self._init_system_messages()
if old is not None:
msg = f"Updated memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
else:
msg = f"Saved memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "delete":
deleted = delete_structured_memory(item["name"], item["scope"], item["scope_id"])
if not deleted:
msg = f"Error: memory '{item['name']}' not found (scope={item['scope']})"
else:
self._init_system_messages()
msg = f"Deleted memory '{item['name']}'"
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "search":
rows = search_structured_memories(
item["query"],
mem_type=item.get("mem_type", ""),
scope=item.get("scope", ""),
scope_id=item.get("scope_id", ""),
limit=item["limit"],
)
if rows:
lines = []
for m in rows:
desc = f"{m['description']}" if m.get("description") else ""
lines.append(
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
f" {m['content'][:500]}"
)
msg = f"Memories ({len(rows)} results):\n" + "\n".join(lines)
else:
msg = (
f"No memories found for '{item['query']}'."
if item["query"]
else "No memories stored."
)
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "list":
rows = list_structured_memories(
mem_type=item.get("mem_type", ""),
scope=item.get("scope", ""),
scope_id=item.get("scope_id", ""),
limit=item["limit"],
)
if rows:
lines = []
for m in rows:
desc = f"{m['description']}" if m.get("description") else ""
lines.append(
f" [{m['type']}:{m['scope']}] {m['name']}{desc}\n"
f" {m['content'][:500]}"
)
msg = f"Memories ({len(rows)}):\n" + "\n".join(lines)
else:
msg = "No memories stored."
self.ui.on_tool_result(call_id, "memory", msg)
return call_id, msg
except Exception as e:
return call_id, f"Error: {e}"
def _exec_forget(self, item: dict[str, Any]) -> tuple[str, str]:
"""Remove a persistent memory by key."""
call_id, key = item["call_id"], item["key"]
try:
deleted = delete_memory(key)
if not deleted:
msg = f"Error: memory '{key}' not found"
else:
self._init_system_messages()
msg = f"Forgot: {key}"
self.ui.on_tool_result(call_id, "forget", msg)
return call_id, msg
except Exception as e:
return call_id, f"Error: {e}"
return call_id, "Error: unexpected action"
def _exec_recall(self, item: dict[str, Any]) -> tuple[str, str]:
"""Search memories and conversation history."""
"""Search conversation history."""
call_id = item["call_id"]
query, limit = item["query"], item["limit"]
parts: list[str] = []
# Memories: list all (no query) or search (with query)
try:
rows = search_memories(query) if query else load_memories()
if rows:
parts.append("Memories:\n" + "\n".join(f" {k}={v}" for k, v in rows))
elif not query:
parts.append("No memories stored.")
except Exception:
pass
conv_rows = search_history(query, limit)
if conv_rows:
lines = []
for ts, sid, role, content, tool_name in conv_rows:
label = f"{role}({tool_name})" if tool_name else role
text = (content or "")[:500]
if content and len(content) > 500:
text += "..."
lines.append(f"[{ts} {sid}] {label}: {text}")
output = f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines)
else:
output = f"No conversation history found for '{query}'."
# Conversations: only when a query is provided
if query:
conv_rows = search_history(query, limit)
if conv_rows:
lines = []
for ts, sid, role, content, tool_name in conv_rows:
label = f"{role}({tool_name})" if tool_name else role
text = (content or "")[:500]
if content and len(content) > 500:
text += "..."
lines.append(f"[{ts} {sid}] {label}: {text}")
parts.append(f"Conversations ({len(conv_rows)} matches):\n" + "\n".join(lines))
output = "\n\n".join(parts) if parts else f"No results for '{query}'."
self.ui.on_tool_result(call_id, "recall", output)
return call_id, output
+169 -59
View File
@@ -14,11 +14,11 @@ from turnstone.core.storage._schema import (
audit_events,
conversations,
intent_verdicts,
memories,
metadata,
orgs,
prompt_templates,
roles,
structured_memories,
tool_policies,
usage_events,
user_roles,
@@ -37,6 +37,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
ROLE_MUTABLE as _ROLE_MUTABLE,
)
from turnstone.core.storage._utils import (
STRUCTURED_MEMORY_MUTABLE as _SMEM_MUTABLE,
)
from turnstone.core.storage._utils import (
TEMPLATE_MUTABLE as _TEMPLATE_MUTABLE,
)
@@ -56,6 +59,11 @@ from turnstone.core.storage._utils import (
log = logging.getLogger(__name__)
def _escape_ilike(s: str) -> str:
"""Escape ILIKE metacharacters for use with ESCAPE '\\\\'."""
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
class PostgreSQLBackend:
"""PostgreSQL implementation of the StorageBackend protocol."""
@@ -274,64 +282,6 @@ class PostgreSQLBackend:
)
conn.commit()
# -- Generic key-value store -----------------------------------------------
def kv_get(self, key: str) -> str | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(memories.c.value).where(memories.c.key == key)).fetchone()
return str(row[0]) if row else None
def kv_set(self, key: str, value: str) -> str | None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(memories.c.value, memories.c.created).where(memories.c.key == key)
).fetchone()
old_value = str(existing[0]) if existing else None
created = str(existing[1]) if existing else now
# Delete + insert for cross-dialect upsert
conn.execute(sa.delete(memories).where(memories.c.key == key))
conn.execute(
sa.insert(memories),
{"key": key, "value": value, "created": created, "updated": now},
)
conn.commit()
return old_value
def kv_delete(self, key: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(sa.delete(memories).where(memories.c.key == key))
conn.commit()
return result.rowcount > 0
def kv_list(self) -> list[tuple[str, str]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(memories.c.key, memories.c.value).order_by(memories.c.key)
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
def kv_search(self, query: str) -> list[tuple[str, str]]:
if not query or not query.strip():
return self.kv_list()
terms = query.split()
with self._engine.connect() as conn:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
clauses.append(f"(key ILIKE :k{i} OR value ILIKE :v{i})")
params[f"k{i}"] = f"%{t}%"
params[f"v{i}"] = f"%{t}%"
rows = conn.execute(
sa.text(
"SELECT key, value FROM memories WHERE "
+ " AND ".join(clauses)
+ " ORDER BY key"
),
params,
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
# -- Workstream operations -------------------------------------------------
def register_workstream(
@@ -2144,6 +2094,166 @@ class PostgreSQLBackend:
row = conn.execute(q).fetchone()
return row[0] if row else 0
# -- Structured memories ---------------------------------------------------
def create_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str,
scope: str,
scope_id: str,
content: str,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(structured_memories),
{
"memory_id": memory_id,
"name": name,
"description": description,
"type": mem_type,
"scope": scope,
"scope_id": scope_id,
"content": content,
"created": now,
"updated": now,
"last_accessed": now,
"access_count": 0,
},
)
conn.commit()
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(structured_memories.c.memory_id == memory_id)
).fetchone()
return dict(row._mapping) if row else None
def get_structured_memory_by_name(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
fields = {k: v for k, v in fields.items() if k in _SMEM_MUTABLE}
if not fields:
return False
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
fields["updated"] = now
fields["last_accessed"] = now
with self._engine.connect() as conn:
result = conn.execute(
sa.update(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
)
conn.commit()
return result.rowcount > 0
def list_structured_memories(
self,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
with self._engine.connect() as conn:
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [dict(r._mapping) for r in rows]
def search_structured_memories(
self,
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
if not query or not query.strip():
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
terms = query.split()
with self._engine.connect() as conn:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_ilike(t)
clauses.append(
f"(name ILIKE :n{i} ESCAPE '\\' "
f"OR description ILIKE :d{i} ESCAPE '\\' "
f"OR content ILIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
where = " AND ".join(clauses)
if mem_type:
where += " AND type = :type_filter"
params["type_filter"] = mem_type
if scope:
where += " AND scope = :scope_filter"
params["scope_filter"] = scope
if scope_id:
where += " AND scope_id = :scope_id_filter"
params["scope_id_filter"] = scope_id
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE {where} "
f"ORDER BY updated DESC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def count_structured_memories(self, scope: str = "", scope_id: str = "") -> int:
with self._engine.connect() as conn:
q = sa.select(sa.func.count()).select_from(structured_memories)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
result = conn.execute(q).scalar()
return int(result or 0)
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+51 -13
View File
@@ -9,8 +9,8 @@ from typing import Any, Protocol, runtime_checkable
class StorageBackend(Protocol):
"""Protocol that every storage backend adapter must implement.
Provides workstream management, conversation persistence, key-value storage
(for memories), and full-text search.
Provides workstream management, conversation persistence, structured
memories, and full-text search.
"""
# -- Core conversation operations ------------------------------------------
@@ -71,26 +71,64 @@ class StorageBackend(Protocol):
"""Set or update the auto-generated title for a workstream."""
...
# -- Generic key-value store (backs memories table) ------------------------
# -- Structured memories ---------------------------------------------------
def kv_get(self, key: str) -> str | None:
"""Get a value by key. Returns None if not found."""
def create_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str,
scope: str,
scope_id: str,
content: str,
) -> None:
"""Create a structured memory record."""
...
def kv_set(self, key: str, value: str) -> str | None:
"""Set a key-value pair. Returns the previous value if it existed."""
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
"""Return structured memory dict or None."""
...
def kv_delete(self, key: str) -> bool:
"""Delete a key. Returns True if the key existed."""
def get_structured_memory_by_name(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
"""Lookup structured memory by (name, scope, scope_id). Returns dict or None."""
...
def kv_list(self) -> list[tuple[str, str]]:
"""Return all (key, value) pairs sorted by key."""
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
"""Update specified fields on a structured memory. Returns True if found."""
...
def kv_search(self, query: str) -> list[tuple[str, str]]:
"""Search key-value pairs by query. Returns matching (key, value) pairs."""
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
"""Delete a structured memory by (name, scope, scope_id). Returns True if existed."""
...
def list_structured_memories(
self,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
"""Return structured memories with optional filters, ordered by updated DESC."""
...
def search_structured_memories(
self,
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
"""Search structured memories by query. Returns matching memory dicts."""
...
def count_structured_memories(self, scope: str = "", scope_id: str = "") -> int:
"""Count structured memories with optional scope filter."""
...
# -- Workstream operations -------------------------------------------------
+12 -4
View File
@@ -9,13 +9,21 @@ import sqlalchemy as sa
metadata = sa.MetaData()
memories = sa.Table(
"memories",
structured_memories = sa.Table(
"structured_memories",
metadata,
sa.Column("key", sa.Text, primary_key=True),
sa.Column("value", sa.Text, nullable=False),
sa.Column("memory_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column("type", sa.Text, nullable=False, server_default="project"),
sa.Column("scope", sa.Text, nullable=False, server_default="global"),
sa.Column("scope_id", sa.Text, nullable=False, server_default=""),
sa.Column("content", sa.Text, nullable=False),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.Column("last_accessed", sa.Text, nullable=False, server_default=""),
sa.Column("access_count", sa.Integer, nullable=False, server_default="0"),
sa.UniqueConstraint("name", "scope", "scope_id", name="uq_smem_name_scope"),
)
conversations = sa.Table(
+164 -63
View File
@@ -14,11 +14,11 @@ from turnstone.core.storage._schema import (
audit_events,
conversations,
intent_verdicts,
memories,
metadata,
orgs,
prompt_templates,
roles,
structured_memories,
tool_policies,
usage_events,
user_roles,
@@ -37,6 +37,9 @@ from turnstone.core.storage._utils import (
from turnstone.core.storage._utils import (
ROLE_MUTABLE as _ROLE_MUTABLE,
)
from turnstone.core.storage._utils import (
STRUCTURED_MEMORY_MUTABLE as _SMEM_MUTABLE,
)
from turnstone.core.storage._utils import (
TEMPLATE_MUTABLE as _TEMPLATE_MUTABLE,
)
@@ -341,68 +344,6 @@ class SQLiteBackend:
)
conn.commit()
# -- Generic key-value store -----------------------------------------------
def kv_get(self, key: str) -> str | None:
with self._engine.connect() as conn:
row = conn.execute(sa.select(memories.c.value).where(memories.c.key == key)).fetchone()
return str(row[0]) if row else None
def kv_set(self, key: str, value: str) -> str | None:
with self._engine.connect() as conn:
existing = conn.execute(
sa.select(memories.c.value).where(memories.c.key == key)
).fetchone()
old_value = str(existing[0]) if existing else None
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
conn.execute(
sa.text(
"INSERT OR REPLACE INTO memories (key, value, created, updated) "
"VALUES (:key, :value, "
"COALESCE((SELECT created FROM memories WHERE key = :key), :now), "
":now)"
),
{"key": key, "value": value, "now": now},
)
conn.commit()
return old_value
def kv_delete(self, key: str) -> bool:
with self._engine.connect() as conn:
result = conn.execute(sa.delete(memories).where(memories.c.key == key))
conn.commit()
return result.rowcount > 0
def kv_list(self) -> list[tuple[str, str]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(memories.c.key, memories.c.value).order_by(memories.c.key)
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
def kv_search(self, query: str) -> list[tuple[str, str]]:
if not query or not query.strip():
return self.kv_list()
terms = query.split()
with self._engine.connect() as conn:
# Build WHERE clause: each term must match key OR value
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_like(t)
clauses.append(f"(key LIKE :k{i} ESCAPE '\\' OR value LIKE :v{i} ESCAPE '\\')")
params[f"k{i}"] = f"%{escaped}%"
params[f"v{i}"] = f"%{escaped}%"
rows = conn.execute(
sa.text(
"SELECT key, value FROM memories WHERE "
+ " AND ".join(clauses)
+ " ORDER BY key"
),
params,
).fetchall()
return [(str(r[0]), str(r[1])) for r in rows]
# -- Workstream operations -------------------------------------------------
def register_workstream(
@@ -2177,6 +2118,166 @@ class SQLiteBackend:
row = conn.execute(q).fetchone()
return row[0] if row else 0
# -- Structured memories ---------------------------------------------------
def create_structured_memory(
self,
memory_id: str,
name: str,
description: str,
mem_type: str,
scope: str,
scope_id: str,
content: str,
) -> None:
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
with self._engine.connect() as conn:
conn.execute(
sa.insert(structured_memories),
{
"memory_id": memory_id,
"name": name,
"description": description,
"type": mem_type,
"scope": scope,
"scope_id": scope_id,
"content": content,
"created": now,
"updated": now,
"last_accessed": now,
"access_count": 0,
},
)
conn.commit()
def get_structured_memory(self, memory_id: str) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(structured_memories.c.memory_id == memory_id)
).fetchone()
return dict(row._mapping) if row else None
def get_structured_memory_by_name(
self, name: str, scope: str = "global", scope_id: str = ""
) -> dict[str, str] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
).fetchone()
return dict(row._mapping) if row else None
def update_structured_memory(self, memory_id: str, **fields: str) -> bool:
fields = {k: v for k, v in fields.items() if k in _SMEM_MUTABLE}
if not fields:
return False
now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S")
fields["updated"] = now
fields["last_accessed"] = now
with self._engine.connect() as conn:
result = conn.execute(
sa.update(structured_memories)
.where(structured_memories.c.memory_id == memory_id)
.values(**fields)
)
conn.commit()
return result.rowcount > 0
def delete_structured_memory(
self, name: str, scope: str = "global", scope_id: str = ""
) -> bool:
with self._engine.connect() as conn:
result = conn.execute(
sa.delete(structured_memories).where(
sa.and_(
structured_memories.c.name == name,
structured_memories.c.scope == scope,
structured_memories.c.scope_id == scope_id,
)
)
)
conn.commit()
return result.rowcount > 0
def list_structured_memories(
self,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 100,
) -> list[dict[str, str]]:
with self._engine.connect() as conn:
q = sa.select(structured_memories).order_by(structured_memories.c.updated.desc())
if mem_type:
q = q.where(structured_memories.c.type == mem_type)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
q = q.limit(limit)
rows = conn.execute(q).fetchall()
return [dict(r._mapping) for r in rows]
def search_structured_memories(
self,
query: str,
mem_type: str = "",
scope: str = "",
scope_id: str = "",
limit: int = 20,
) -> list[dict[str, str]]:
if not query or not query.strip():
return self.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
terms = query.split()
with self._engine.connect() as conn:
clauses = []
params: dict[str, str] = {}
for i, t in enumerate(terms):
escaped = _escape_like(t)
clauses.append(
f"(name LIKE :n{i} ESCAPE '\\' "
f"OR description LIKE :d{i} ESCAPE '\\' "
f"OR content LIKE :c{i} ESCAPE '\\')"
)
params[f"n{i}"] = f"%{escaped}%"
params[f"d{i}"] = f"%{escaped}%"
params[f"c{i}"] = f"%{escaped}%"
where = " AND ".join(clauses)
if mem_type:
where += " AND type = :type_filter"
params["type_filter"] = mem_type
if scope:
where += " AND scope = :scope_filter"
params["scope_filter"] = scope
if scope_id:
where += " AND scope_id = :scope_id_filter"
params["scope_id_filter"] = scope_id
rows = conn.execute(
sa.text(
f"SELECT * FROM structured_memories WHERE {where} "
f"ORDER BY updated DESC LIMIT :lim"
),
{**params, "lim": limit},
).fetchall()
return [dict(r._mapping) for r in rows]
def count_structured_memories(self, scope: str = "", scope_id: str = "") -> int:
with self._engine.connect() as conn:
q = sa.select(sa.func.count()).select_from(structured_memories)
if scope:
q = q.where(structured_memories.c.scope == scope)
if scope_id:
q = q.where(structured_memories.c.scope_id == scope_id)
result = conn.execute(q).scalar()
return int(result or 0)
# -- Lifecycle -------------------------------------------------------------
def close(self) -> None:
+1
View File
@@ -47,6 +47,7 @@ WS_TEMPLATE_MUTABLE = frozenset(
"enabled",
}
)
STRUCTURED_MEMORY_MUTABLE = frozenset({"content", "description", "type"})
VERDICT_MUTABLE = frozenset(
{
"user_decision",
@@ -0,0 +1,76 @@
"""Create structured_memories table and migrate existing flat memories.
Revision ID: 014
Revises: 013
Create Date: 2026-03-13
"""
import sqlalchemy as sa
from alembic import op
revision = "014"
down_revision = "013"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"structured_memories",
sa.Column("memory_id", sa.Text, primary_key=True),
sa.Column("name", sa.Text, nullable=False),
sa.Column("description", sa.Text, nullable=False, server_default=""),
sa.Column("type", sa.Text, nullable=False, server_default="project"),
sa.Column("scope", sa.Text, nullable=False, server_default="global"),
sa.Column("scope_id", sa.Text, nullable=False, server_default=""),
sa.Column("content", sa.Text, nullable=False),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
sa.Column("last_accessed", sa.Text, nullable=False, server_default=""),
sa.Column("access_count", sa.Integer, nullable=False, server_default="0"),
)
op.create_unique_constraint(
"uq_smem_name_scope", "structured_memories", ["name", "scope", "scope_id"]
)
op.create_index("idx_smem_type", "structured_memories", ["type"])
op.create_index("idx_smem_scope", "structured_memories", ["scope", "scope_id"])
# Migrate existing flat memories into structured_memories
conn = op.get_bind()
conn.execute(
sa.text(
"INSERT INTO structured_memories "
"(memory_id, name, description, type, scope, scope_id, content, created, updated) "
"SELECT "
" 'migrated-' || key, "
" key, "
" '', "
" 'project', "
" 'global', "
" '', "
" value, "
" created, "
" updated "
"FROM memories"
)
)
op.drop_table("memories")
def downgrade() -> None:
op.create_table(
"memories",
sa.Column("key", sa.Text, primary_key=True),
sa.Column("value", sa.Text, nullable=False),
sa.Column("created", sa.Text, nullable=False),
sa.Column("updated", sa.Text, nullable=False),
)
conn = op.get_bind()
conn.execute(
sa.text(
"INSERT INTO memories (key, value, created, updated) "
"SELECT name, content, created, updated "
"FROM structured_memories WHERE scope = 'global'"
)
)
op.drop_table("structured_memories")
+1 -57
View File
@@ -8,67 +8,11 @@ models (vLLM, llama.cpp) use the client-side BM25 fallback here.
from __future__ import annotations
import math
import re
from collections import Counter
from typing import Any
# ---------------------------------------------------------------------------
# BM25 index — lightweight, pure-Python, zero external deps
# ---------------------------------------------------------------------------
_SPLIT_RE = re.compile(r"[_\-./\s]+")
def _tokenize(text: str) -> list[str]:
"""Split text on whitespace, underscores, hyphens, dots."""
return [t.lower() for t in _SPLIT_RE.split(text) if t]
class BM25Index:
"""Okapi BM25 index over tool name + description text."""
def __init__(self, documents: list[str], *, k1: float = 1.5, b: float = 0.75) -> None:
self.k1 = k1
self.b = b
self._docs = documents
self._doc_tokens: list[list[str]] = [_tokenize(d) for d in documents]
self._doc_lens = [len(t) for t in self._doc_tokens]
self._avgdl = sum(self._doc_lens) / max(len(self._doc_lens), 1)
self._n = len(documents)
# Document frequency per term
self._df: Counter[str] = Counter()
for tokens in self._doc_tokens:
for term in set(tokens):
self._df[term] += 1
def search(self, query: str, k: int = 5) -> list[int]:
"""Return indices of top-k documents sorted by descending BM25 score."""
q_tokens = _tokenize(query)
if not q_tokens:
return []
scores: list[tuple[float, int]] = []
for idx, doc_tokens in enumerate(self._doc_tokens):
score = self._score(q_tokens, doc_tokens, self._doc_lens[idx])
if score > 0:
scores.append((score, idx))
scores.sort(key=lambda x: (-x[0], x[1]))
return [idx for _, idx in scores[:k]]
def _score(self, q_tokens: list[str], doc_tokens: list[str], dl: int) -> float:
tf_map: Counter[str] = Counter(doc_tokens)
score = 0.0
for term in q_tokens:
if term not in tf_map:
continue
tf = tf_map[term]
df = self._df.get(term, 0)
idf = math.log((self._n - df + 0.5) / (df + 0.5) + 1.0)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avgdl)
score += idf * numerator / denominator
return score
from turnstone.core.bm25 import BM25Index, _tokenize # noqa: F401
# ---------------------------------------------------------------------------
# Tool search manager — partitions tools, tracks visibility
+1 -1
View File
@@ -55,7 +55,7 @@ if TYPE_CHECKING:
log = logging.getLogger("turnstone.mq.bridge")
# Server's default safe tools (auto-approved without user confirmation)
DEFAULT_SAFE_TOOLS = frozenset(["read_file", "search", "man", "remember", "recall", "forget"])
DEFAULT_SAFE_TOOLS = frozenset(["read_file", "search", "man", "memory", "recall"])
class Bridge:
+2
View File
@@ -2013,6 +2013,7 @@ def main() -> None:
) -> ChatSession:
assert ui is not None
r_client, r_model, r_cfg = registry.resolve(model_alias)
uid = getattr(ui, "_user_id", "") or ""
return ChatSession(
client=r_client,
model=r_model,
@@ -2038,6 +2039,7 @@ def main() -> None:
tool_search_max_results=args.tool_search_max_results,
template=args.template,
judge_config=judge_config,
user_id=uid,
)
# Create WatchRunner (periodic command polling, server-level)
-15
View File
@@ -1,15 +0,0 @@
{
"name": "forget",
"description": "Remove a persistent memory by key. Use when the user asks to forget, remove, or delete a stored memory.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "The memory key to remove (e.g. 'user_name')."
}
},
"required": ["key"]
},
"primary_key": "key"
}
+46
View File
@@ -0,0 +1,46 @@
{
"name": "memory",
"description": "Persistent memory across sessions. Actions: 'save' stores a memory, 'search' finds memories by query, 'delete' removes a memory, 'list' shows all memories. Memories have a type (user/project/feedback/reference) and scope (global/workstream/user).",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["save", "search", "delete", "list"],
"description": "Action to perform."
},
"name": {
"type": "string",
"description": "Memory identifier (required for 'save' and 'delete'). Short snake_case key."
},
"content": {
"type": "string",
"description": "Memory content (required for 'save')."
},
"description": {
"type": "string",
"description": "Short description for relevance matching (recommended for 'save')."
},
"type": {
"type": "string",
"enum": ["user", "project", "feedback", "reference"],
"description": "Memory type. Default: 'project'."
},
"scope": {
"type": "string",
"enum": ["global", "workstream", "user"],
"description": "Memory scope. Default: 'global'. Use 'workstream' for context private to this workstream, 'user' for context that follows the user across workstreams."
},
"query": {
"type": "string",
"description": "Search query (for 'search' action)."
},
"limit": {
"type": "integer",
"description": "Max results for 'search' or 'list'. Default: 20."
}
},
"required": ["action"]
},
"primary_key": "name"
}
+5 -4
View File
@@ -1,18 +1,19 @@
{
"name": "recall",
"description": "Search memories and past conversations. With no query, lists all saved memories. With a query, searches both memories and conversation history.",
"description": "Search conversation history for past messages, tool results, and interactions across sessions.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term or phrase. Omit to list all memories."
"description": "Search term or phrase to find in conversation history."
},
"limit": {
"type": "integer",
"description": "Max conversation results to return (default 20)."
"description": "Max results to return (default 20)."
}
}
},
"required": ["query"]
},
"primary_key": "query"
}
-19
View File
@@ -1,19 +0,0 @@
{
"name": "remember",
"description": "Save a persistent memory. Memories persist across sessions. Use to remember IPs, paths, commands, conventions, or any fact worth recalling later.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Short identifier (e.g. 'user_name')."
},
"value": {
"type": "string",
"description": "Content to remember."
}
},
"required": ["key", "value"]
},
"primary_key": "key"
}