p-layers 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- p_layers-0.1.0/LICENSE +21 -0
- p_layers-0.1.0/PKG-INFO +13 -0
- p_layers-0.1.0/README.md +185 -0
- p_layers-0.1.0/p_layer/__init__.py +1 -0
- p_layers-0.1.0/p_layer/core/__init__.py +5 -0
- p_layers-0.1.0/p_layer/core/db.py +412 -0
- p_layers-0.1.0/p_layer/core/memory.py +114 -0
- p_layers-0.1.0/p_layer/core/ontology.py +49 -0
- p_layers-0.1.0/p_layer/mcp/__init__.py +0 -0
- p_layers-0.1.0/p_layer/mcp/server.py +335 -0
- p_layers-0.1.0/p_layers.egg-info/PKG-INFO +13 -0
- p_layers-0.1.0/p_layers.egg-info/SOURCES.txt +16 -0
- p_layers-0.1.0/p_layers.egg-info/dependency_links.txt +1 -0
- p_layers-0.1.0/p_layers.egg-info/entry_points.txt +2 -0
- p_layers-0.1.0/p_layers.egg-info/requires.txt +6 -0
- p_layers-0.1.0/p_layers.egg-info/top_level.txt +1 -0
- p_layers-0.1.0/pyproject.toml +27 -0
- p_layers-0.1.0/setup.cfg +4 -0
p_layers-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 humanerd-drew
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
p_layers-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: p-layers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 7 governance layers for AI agent memory — P0-P6 with KnowledgeDB, MCP server, and wiki-compile
|
|
5
|
+
Project-URL: Home, https://github.com/humanerd-drew/p-layer
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: mcp>=1.0
|
|
9
|
+
Provides-Extra: pg
|
|
10
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "pg"
|
|
11
|
+
Requires-Dist: pgvector>=0.3; extra == "pg"
|
|
12
|
+
Requires-Dist: numpy>=1.24; extra == "pg"
|
|
13
|
+
Dynamic: license-file
|
p_layers-0.1.0/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# P-Layer
|
|
2
|
+
|
|
3
|
+
**7 layers. 1 memory. Zero chaos.**
|
|
4
|
+
|
|
5
|
+
Organize your AI agent's memory into governance layers — from immutable rules (P0) to incident retrospectives (P6). Each layer has a contract: who can write, when to query, how to maintain.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
pip install p-layers # PyPI: p-layers, import: p_layer
|
|
9
|
+
python3 -m p_layer.mcp.server # starts MCP server with SQLite (zero config)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The 7 Layers
|
|
13
|
+
|
|
14
|
+
| Layer | Name | Purpose | Query | Write Access |
|
|
15
|
+
|-------|------|---------|-------|-------------|
|
|
16
|
+
| **P0** | brainstem | Immutable rules | Every session start | system only |
|
|
17
|
+
| **P1** | limbic | Identity & persona | Session start + output | human only |
|
|
18
|
+
| **P2** | hippocampus | Raw session archive | **Last resort** | append-only |
|
|
19
|
+
| **P3** | sensors | Tool integrations | When debugging | system + cron |
|
|
20
|
+
| **P4** | cortex | Skills & growth | Skill selection | agent + manual |
|
|
21
|
+
| **P5** | ego | **Compiled wiki** | **1st priority** | auto-generated |
|
|
22
|
+
| **P6** | prefrontal | Incidents & RCA | During RCA | agent + manual |
|
|
23
|
+
|
|
24
|
+
## Real-world flow
|
|
25
|
+
|
|
26
|
+
An AI assistant discovers a bug in the build pipeline:
|
|
27
|
+
|
|
28
|
+
1. **P6** — Writes an incident report with timeline + root cause
|
|
29
|
+
2. **P0** — If the root cause was a rule violation, proposes a P0 amendment
|
|
30
|
+
3. **`knowledge_recall`** — When similar symptoms appear weeks later, the MCP server surfaces the incident ranked by confidence + freshness + serendipity
|
|
31
|
+
4. **`wiki_compile.py`** — End of day, all incidents + fixes are compiled into P5 wiki pages
|
|
32
|
+
5. **Query routing** — Next session, the compiled knowledge is found instantly (P5 first, P2 last)
|
|
33
|
+
|
|
34
|
+
The same bug never happens twice — not because the agent remembers, but because the governance layer learned.
|
|
35
|
+
|
|
36
|
+
## MCP Server
|
|
37
|
+
|
|
38
|
+
7 tools, all included:
|
|
39
|
+
|
|
40
|
+
| Tool | What it does |
|
|
41
|
+
|------|-------------|
|
|
42
|
+
| `knowledge_remember` | Store a fact with confidence, TTL, version label |
|
|
43
|
+
| `knowledge_recall` | Ranked search — confidence + freshness + 5% serendipity |
|
|
44
|
+
| `knowledge_forget` | Soft-delete (supersede, never destroy) |
|
|
45
|
+
| `knowledge_update` | Update by ID — old version is superseded, history preserved |
|
|
46
|
+
| `knowledge_memory-stats` | Entry counts by layer |
|
|
47
|
+
| `knowledge_snapshot-create` | Freeze current state under a version label |
|
|
48
|
+
| `knowledge_snapshot-rollback` | Supersede entries created after a snapshot |
|
|
49
|
+
|
|
50
|
+
### MCP client configuration
|
|
51
|
+
|
|
52
|
+
**opencode** (`opencode.jsonc`):
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcp": {
|
|
56
|
+
"p-layer": {
|
|
57
|
+
"type": "local",
|
|
58
|
+
"command": ["python3", "-m", "p_layer.mcp.server"],
|
|
59
|
+
"env": { "KNOWLEDGE_PG_DSN": "{env:KNOWLEDGE_PG_DSN}" },
|
|
60
|
+
"enabled": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Claude Desktop** (`claude_desktop_config.json`):
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"p-layer": {
|
|
71
|
+
"command": "python3",
|
|
72
|
+
"args": ["-m", "p_layer.mcp.server"],
|
|
73
|
+
"env": { "KNOWLEDGE_PG_DSN": "" }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Architecture
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
P0-brainstem (rules) ─────────── governs all layer write permissions
|
|
83
|
+
P1-limbic (persona) ──────────── defines agent voice
|
|
84
|
+
│
|
|
85
|
+
P2-hippocampus (raw data) ──────────────┤
|
|
86
|
+
│ │
|
|
87
|
+
├──→ sessions/ (append-only logs) │
|
|
88
|
+
├──→ memories/ (extracted entries) │
|
|
89
|
+
└──→ knowledge/ (ingested artifacts) │
|
|
90
|
+
▼
|
|
91
|
+
P3-sensors ──→ MCP configs ──→ P_LAYER KNOWLEDGEDB ←── P4-cortex skill index
|
|
92
|
+
(Pg + SQLite) │
|
|
93
|
+
│ │
|
|
94
|
+
┌──────────────────────────┤ │
|
|
95
|
+
▼ ▼ ▼
|
|
96
|
+
knowledge_recall P5-ego/wiki/compiled/ P6-prefrontal
|
|
97
|
+
(ranked FTS + vector) (auto-generated daily) (incidents + RCA)
|
|
98
|
+
│
|
|
99
|
+
wiki_lint.py
|
|
100
|
+
(broken link check)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Backend selection
|
|
104
|
+
|
|
105
|
+
| Variable | Effect |
|
|
106
|
+
|----------|--------|
|
|
107
|
+
| `KNOWLEDGE_PG_DSN` unset | SQLite mode (`.knowledge/knowledge.db`) |
|
|
108
|
+
| `KNOWLEDGE_PG_DSN=dbname=...` | PostgreSQL primary, SQLite fallback |
|
|
109
|
+
| `KNOWLEDGE_DB_DIR=/path` | Custom SQLite directory |
|
|
110
|
+
|
|
111
|
+
## Query Routing (priority order)
|
|
112
|
+
|
|
113
|
+
When an agent searches for information:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
1. P5-ego/wiki/compiled/ ← compiled wiki (check FIRST)
|
|
117
|
+
2. P5-ego/memory/ ← saved preferences
|
|
118
|
+
3. P2-hippocampus/knowledge/ ← raw ingested knowledge
|
|
119
|
+
4. P2-hippocampus/memories/ ← raw session memory
|
|
120
|
+
5. P2-hippocampus/sessions/ ← raw session logs (LAST resort)
|
|
121
|
+
6. KnowledgeDB (SQLite/Pg) ← cross-cut fallback
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Steps 1-2 should cover ~80% of queries. Steps 3+ are gaps → next wiki-compile cycle.
|
|
125
|
+
|
|
126
|
+
## Using p-layers in your project
|
|
127
|
+
|
|
128
|
+
The `p-layers/` directory contains the canonical governance contracts. Each map to a runtime directory in your project:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
your-project/
|
|
132
|
+
├── p-layers/ ← contract docs (canonical, read-only)
|
|
133
|
+
│ ├── P0-brainstem/README.md
|
|
134
|
+
│ └── ...
|
|
135
|
+
├── P2-hippocampus/ ← runtime data (your sessions, archives)
|
|
136
|
+
│ └── sessions/
|
|
137
|
+
├── P5-ego/
|
|
138
|
+
│ └── wiki/compiled/ ← auto-generated by wiki_compile.py
|
|
139
|
+
└── P6-prefrontal/
|
|
140
|
+
└── incidents/ ← your incident reports
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Copy** → `cp -r p-layers/ your-project/` (you own them, customize freely)\
|
|
144
|
+
**Submodule** → `git submodule add <url>` (stay in sync) \
|
|
145
|
+
**Refer** → point your agent's init workflow at `p-layers/P0-brainstem/README.md`
|
|
146
|
+
|
|
147
|
+
## Scripts
|
|
148
|
+
|
|
149
|
+
| Script | Purpose |
|
|
150
|
+
|--------|---------|
|
|
151
|
+
| `scripts/ontology_setup.py` | Initialize entity type hierarchy + relation constraints |
|
|
152
|
+
| `scripts/seed_knowledge_db.py` | Bootstrap knowledge.db with schema + seeds |
|
|
153
|
+
| `scripts/ingest_fact.py` | Insert a single fact from CLI |
|
|
154
|
+
| `scripts/ingest_instructions.py` | Batch-ingest .md files into KnowledgeDB |
|
|
155
|
+
| `scripts/inference.py` | Transitive closure, backtrace, contradiction detection |
|
|
156
|
+
| `scripts/wiki_compile.py` | KnowledgeDB → Markdown wiki pages + INDEX.json |
|
|
157
|
+
| `scripts/wiki_lint.py` | Broken link detection, INDEX consistency |
|
|
158
|
+
|
|
159
|
+
## Ontology Layer
|
|
160
|
+
|
|
161
|
+
24 entity types across 6 root categories:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
artifact → doc, code, project
|
|
165
|
+
agent → persona, tool, script, skill
|
|
166
|
+
decision → pattern, preference
|
|
167
|
+
event → incident, session
|
|
168
|
+
knowledge → concept, paper, reference
|
|
169
|
+
meta → category, _task, fact
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Relation constraints enforce type safety at insert time:
|
|
173
|
+
|
|
174
|
+
| Relation | Source → Target |
|
|
175
|
+
|----------|----------------|
|
|
176
|
+
| `depends_on` | any → tool/script/skill |
|
|
177
|
+
| `fixed_by` | incident → pattern/decision |
|
|
178
|
+
| `caused` | decision/pattern → incident |
|
|
179
|
+
| `led_to` | decision → decision |
|
|
180
|
+
| `cites` | paper → paper |
|
|
181
|
+
| `contradicts` | decision/pattern → decision/pattern |
|
|
182
|
+
|
|
183
|
+
## License
|
|
184
|
+
|
|
185
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""p-layer — Knowledge governance layers for AI agents."""
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
KnowledgeDB — Unified PgVector + SQLite fallback access.
|
|
3
|
+
|
|
4
|
+
Single entry point for all database operations.
|
|
5
|
+
Layer-aware: P0 (rules) through P6 (incidents).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
db = KnowledgeDB()
|
|
9
|
+
db.insert(layer='P5', type='knowledge', content='...')
|
|
10
|
+
results = db.search('query string', layers=['P5', 'P6'])
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sqlite3
|
|
18
|
+
import time
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import numpy as np
|
|
26
|
+
import psycopg2
|
|
27
|
+
from pgvector.psycopg2 import register_vector
|
|
28
|
+
HAS_PG = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
np = None
|
|
31
|
+
psycopg2 = None
|
|
32
|
+
register_vector = None
|
|
33
|
+
HAS_PG = False
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
DEFAULT_DB_DIR = os.environ.get("KNOWLEDGE_DB_DIR", str(Path.cwd() / ".knowledge"))
|
|
38
|
+
PG_DSN = os.environ.get("KNOWLEDGE_PG_DSN", "")
|
|
39
|
+
|
|
40
|
+
LAYER_AUTHORITY = {
|
|
41
|
+
"P0": 100, "P1": 80, "P2": 60, "P3": 50, "P4": 40, "P5": 30, "P6": 20,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
LAYER_WRITERS = {
|
|
45
|
+
"P0": frozenset({"system"}),
|
|
46
|
+
"P1": frozenset({"system"}),
|
|
47
|
+
"P2": frozenset({"system", "gateway", "cron"}),
|
|
48
|
+
"P3": frozenset({"system", "gateway", "cron"}),
|
|
49
|
+
"P4": frozenset({"system", "cron", "agent", "manual"}),
|
|
50
|
+
"P5": frozenset({"system", "cron", "agent", "manual", "tool"}),
|
|
51
|
+
"P6": frozenset({"system", "cron", "agent", "manual", "tool"}),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DatabaseError(Exception):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class WriteDenied(DatabaseError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConnectionError(DatabaseError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class KnowledgeDB:
|
|
68
|
+
"""Unified database access with Pg primary + SQLite fallback."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, mode: str = "auto", dsn: str = None,
|
|
71
|
+
db_dir: str = None):
|
|
72
|
+
self.mode = mode
|
|
73
|
+
self.dsn = dsn or PG_DSN
|
|
74
|
+
self.db_dir = Path(db_dir or DEFAULT_DB_DIR)
|
|
75
|
+
self._pg_conn = None
|
|
76
|
+
self._sqlite_conn = None
|
|
77
|
+
if HAS_PG:
|
|
78
|
+
self._connect_pg()
|
|
79
|
+
|
|
80
|
+
def _connect_pg(self):
|
|
81
|
+
if self.mode == "sqlite" or not self.dsn:
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
conn = psycopg2.connect(self.dsn)
|
|
85
|
+
conn.set_session(autocommit=True)
|
|
86
|
+
register_vector(conn)
|
|
87
|
+
self._pg_conn = conn
|
|
88
|
+
except Exception as e:
|
|
89
|
+
if self.mode == "pg":
|
|
90
|
+
raise ConnectionError(f"Pg connection failed: {e}")
|
|
91
|
+
logger.warning("Pg unavailable, using SQLite fallback: %s", e)
|
|
92
|
+
self.mode = "sqlite"
|
|
93
|
+
|
|
94
|
+
def _connect_sqlite(self):
|
|
95
|
+
if self._sqlite_conn is None:
|
|
96
|
+
path = self.db_dir / "knowledge.db"
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
self._sqlite_conn = sqlite3.connect(str(path))
|
|
99
|
+
self._sqlite_conn.row_factory = sqlite3.Row
|
|
100
|
+
return self._sqlite_conn
|
|
101
|
+
|
|
102
|
+
def set_mode(self, mode: str):
|
|
103
|
+
if mode not in ("auto", "pg", "sqlite"):
|
|
104
|
+
raise ValueError(f"Invalid mode: {mode}")
|
|
105
|
+
self.mode = mode
|
|
106
|
+
if mode == "pg" and self._pg_conn is None:
|
|
107
|
+
self._connect_pg()
|
|
108
|
+
if mode == "sqlite" and self._sqlite_conn is None:
|
|
109
|
+
self._connect_sqlite()
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def available(self) -> bool:
|
|
113
|
+
if self.mode == "sqlite":
|
|
114
|
+
return True
|
|
115
|
+
if self._pg_conn is None:
|
|
116
|
+
return False
|
|
117
|
+
try:
|
|
118
|
+
cur = self._pg_conn.cursor()
|
|
119
|
+
cur.execute("SELECT 1")
|
|
120
|
+
cur.close()
|
|
121
|
+
return True
|
|
122
|
+
except Exception:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def _check_layer_write(self, layer: str, who: str):
|
|
126
|
+
allowed = LAYER_WRITERS.get(layer, frozenset())
|
|
127
|
+
who_prefix = who.split(":")[0] if ":" in who else who
|
|
128
|
+
if who_prefix not in allowed and who not in allowed:
|
|
129
|
+
raise WriteDenied(
|
|
130
|
+
f"Layer {layer} does not allow writes from '{who}'. "
|
|
131
|
+
f"Allowed: {sorted(allowed)}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def insert(self, layer: str, type: str, content: str,
|
|
135
|
+
who: str = "system", source: str = "",
|
|
136
|
+
source_path: str = "", embedding: list = None,
|
|
137
|
+
created_at: str = None) -> dict:
|
|
138
|
+
if layer not in LAYER_AUTHORITY:
|
|
139
|
+
raise ValueError(f"Invalid layer: {layer}. Must be P0-P6")
|
|
140
|
+
self._check_layer_write(layer, who)
|
|
141
|
+
|
|
142
|
+
data = {
|
|
143
|
+
"who": who, "type": type, "layer": layer,
|
|
144
|
+
"authority": LAYER_AUTHORITY[layer],
|
|
145
|
+
"content": content[:100000],
|
|
146
|
+
"source": source, "source_path": source_path,
|
|
147
|
+
}
|
|
148
|
+
if created_at:
|
|
149
|
+
data["created_at"] = created_at
|
|
150
|
+
|
|
151
|
+
if self.mode != "sqlite" and self._pg_conn:
|
|
152
|
+
try:
|
|
153
|
+
return self._pg_insert(data, embedding)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.error("Pg insert failed, trying fallback: %s", e)
|
|
156
|
+
if self.mode == "pg":
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
return self._sqlite_insert(data)
|
|
160
|
+
|
|
161
|
+
def _pg_insert(self, data: dict, embedding: list = None) -> dict:
|
|
162
|
+
cur = self._pg_conn.cursor()
|
|
163
|
+
cols = list(data.keys())
|
|
164
|
+
placeholders = ["%s"] * len(cols)
|
|
165
|
+
values = [data[k] for k in cols]
|
|
166
|
+
|
|
167
|
+
if embedding is not None:
|
|
168
|
+
cols.append("embedding")
|
|
169
|
+
placeholders.append("%s")
|
|
170
|
+
values.append(np.array(embedding, dtype=np.float32))
|
|
171
|
+
|
|
172
|
+
cur.execute(
|
|
173
|
+
f"INSERT INTO entries ({', '.join(cols)}) "
|
|
174
|
+
f"VALUES ({', '.join(placeholders)}) "
|
|
175
|
+
f"RETURNING id, who, type, layer, authority, created_at",
|
|
176
|
+
values
|
|
177
|
+
)
|
|
178
|
+
row = cur.fetchone()
|
|
179
|
+
if not row:
|
|
180
|
+
raise DatabaseError("INSERT RETURNING returned no rows")
|
|
181
|
+
return {
|
|
182
|
+
"id": row[0], "who": row[1], "type": row[2],
|
|
183
|
+
"layer": row[3], "authority": row[4],
|
|
184
|
+
"created_at": row[5].isoformat() if row[5] else None,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
def _sqlite_insert(self, data: dict) -> dict:
|
|
188
|
+
conn = self._connect_sqlite()
|
|
189
|
+
conn.execute(
|
|
190
|
+
"INSERT INTO memory_fts (fact, type) VALUES (?, ?)",
|
|
191
|
+
(data["content"], data["type"])
|
|
192
|
+
)
|
|
193
|
+
conn.commit()
|
|
194
|
+
return {"id": conn.execute("SELECT last_insert_rowid()").fetchone()[0], **data}
|
|
195
|
+
|
|
196
|
+
def search(self, query: str, layers: list = None,
|
|
197
|
+
limit: int = 20, who: str = None) -> list:
|
|
198
|
+
if self.mode != "sqlite" and self._pg_conn:
|
|
199
|
+
try:
|
|
200
|
+
return self._pg_search(query, layers, limit, who)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error("Pg search failed, fallback: %s", e)
|
|
203
|
+
if self.mode == "pg":
|
|
204
|
+
raise
|
|
205
|
+
return self._sqlite_search(query, limit)
|
|
206
|
+
|
|
207
|
+
def _pg_search(self, query: str, layers: list = None,
|
|
208
|
+
limit: int = 20, who: str = None) -> list:
|
|
209
|
+
cur = self._pg_conn.cursor()
|
|
210
|
+
conditions = []
|
|
211
|
+
params = []
|
|
212
|
+
|
|
213
|
+
if layers:
|
|
214
|
+
placeholders = ", ".join(f"'{l}'" for l in layers)
|
|
215
|
+
conditions.append(f"layer IN ({placeholders})")
|
|
216
|
+
if who:
|
|
217
|
+
conditions.append("who = %s")
|
|
218
|
+
params.append(who)
|
|
219
|
+
if query:
|
|
220
|
+
conditions.append("tsv @@ plainto_tsquery('english', %s)")
|
|
221
|
+
params.append(query)
|
|
222
|
+
|
|
223
|
+
where = " AND ".join(conditions) if conditions else "TRUE"
|
|
224
|
+
sql = f"""
|
|
225
|
+
SELECT id, who, type, layer, authority,
|
|
226
|
+
substring(content, 1, 200) as content_preview,
|
|
227
|
+
created_at,
|
|
228
|
+
ts_rank(tsv, plainto_tsquery('english', %s)) as rank
|
|
229
|
+
FROM entries
|
|
230
|
+
WHERE {where}
|
|
231
|
+
ORDER BY authority DESC, rank DESC, created_at DESC
|
|
232
|
+
LIMIT {limit}
|
|
233
|
+
"""
|
|
234
|
+
all_params = [query] + params if query else params
|
|
235
|
+
cur.execute(sql, all_params if params else [query] if query else [])
|
|
236
|
+
|
|
237
|
+
results = []
|
|
238
|
+
for r in cur.fetchall():
|
|
239
|
+
results.append({
|
|
240
|
+
"id": r[0], "who": r[1], "type": r[2],
|
|
241
|
+
"layer": r[3], "authority": r[4],
|
|
242
|
+
"content": r[5], "created_at": r[6].isoformat() if r[6] else None,
|
|
243
|
+
})
|
|
244
|
+
return results
|
|
245
|
+
|
|
246
|
+
def _sqlite_search(self, query: str, limit: int = 20) -> list:
|
|
247
|
+
conn = self._connect_sqlite()
|
|
248
|
+
try:
|
|
249
|
+
rows = conn.execute(
|
|
250
|
+
"SELECT rowid, fact, type FROM memory_fts "
|
|
251
|
+
"WHERE memory_fts MATCH ? LIMIT ?",
|
|
252
|
+
(query, limit)
|
|
253
|
+
).fetchall()
|
|
254
|
+
return [{"id": r[0], "content": r[1], "type": r[2], "layer": "P5"} for r in rows]
|
|
255
|
+
except sqlite3.OperationalError:
|
|
256
|
+
return []
|
|
257
|
+
|
|
258
|
+
def vector_search(self, embedding: list, layers: list = None,
|
|
259
|
+
limit: int = 10) -> list:
|
|
260
|
+
if self.mode == "sqlite" or self._pg_conn is None:
|
|
261
|
+
return []
|
|
262
|
+
cur = self._pg_conn.cursor()
|
|
263
|
+
vec = np.array(embedding, dtype=np.float32)
|
|
264
|
+
layer_filter = ""
|
|
265
|
+
if layers:
|
|
266
|
+
placeholders = ", ".join(f"'{l}'" for l in layers)
|
|
267
|
+
layer_filter = f"AND layer IN ({placeholders})"
|
|
268
|
+
|
|
269
|
+
cur.execute(
|
|
270
|
+
f"""SELECT id, who, type, layer, authority,
|
|
271
|
+
substring(content, 1, 200) as preview,
|
|
272
|
+
created_at,
|
|
273
|
+
1 - (embedding <=> %s) as similarity
|
|
274
|
+
FROM entries WHERE embedding IS NOT NULL {layer_filter}
|
|
275
|
+
ORDER BY embedding <=> %s LIMIT {limit}""",
|
|
276
|
+
(vec, vec)
|
|
277
|
+
)
|
|
278
|
+
results = []
|
|
279
|
+
for r in cur.fetchall():
|
|
280
|
+
results.append({
|
|
281
|
+
"id": r[0], "who": r[1], "type": r[2],
|
|
282
|
+
"layer": r[3], "authority": r[4],
|
|
283
|
+
"content": r[5], "created_at": r[6].isoformat() if r[6] else None,
|
|
284
|
+
"similarity": round(r[7], 4),
|
|
285
|
+
})
|
|
286
|
+
return results
|
|
287
|
+
|
|
288
|
+
def hybrid_search(self, query: str, embedding: list,
|
|
289
|
+
layers: list = None, limit: int = 10) -> list:
|
|
290
|
+
if self.mode == "sqlite" or self._pg_conn is None:
|
|
291
|
+
return self._sqlite_search(query, limit)
|
|
292
|
+
cur = self._pg_conn.cursor()
|
|
293
|
+
vec = np.array(embedding, dtype=np.float32)
|
|
294
|
+
layer_filter = ""
|
|
295
|
+
if layers:
|
|
296
|
+
placeholders = ", ".join(f"'{l}'" for l in layers)
|
|
297
|
+
layer_filter = f"AND layer IN ({placeholders})"
|
|
298
|
+
|
|
299
|
+
cur.execute(
|
|
300
|
+
f"""SELECT id, who, type, layer, authority,
|
|
301
|
+
substring(content, 1, 200) as preview, created_at,
|
|
302
|
+
ts_rank(tsv, plainto_tsquery('english', %s)) as fts_score,
|
|
303
|
+
1 - (embedding <=> %s) as vec_score,
|
|
304
|
+
(ts_rank(tsv, plainto_tsquery('english', %s)) * 0.3 +
|
|
305
|
+
(1 - (embedding <=> %s)) * 0.7) as combined
|
|
306
|
+
FROM entries
|
|
307
|
+
WHERE (tsv @@ plainto_tsquery('english', %s) OR embedding IS NOT NULL)
|
|
308
|
+
{layer_filter}
|
|
309
|
+
ORDER BY combined DESC LIMIT {limit}""",
|
|
310
|
+
(query, vec, query, vec, query)
|
|
311
|
+
)
|
|
312
|
+
results = []
|
|
313
|
+
for r in cur.fetchall():
|
|
314
|
+
results.append({
|
|
315
|
+
"id": r[0], "who": r[1], "type": r[2],
|
|
316
|
+
"layer": r[3], "authority": r[4],
|
|
317
|
+
"content": r[5], "created_at": r[6].isoformat() if r[6] else None,
|
|
318
|
+
"combined_score": round(r[9], 4),
|
|
319
|
+
})
|
|
320
|
+
return results
|
|
321
|
+
|
|
322
|
+
def get_layer(self, layer: str, limit: int = 50) -> list:
|
|
323
|
+
return self.search("", layers=[layer], limit=limit)
|
|
324
|
+
|
|
325
|
+
def get_layer_count(self, layer: str = None) -> dict:
|
|
326
|
+
if self.mode == "sqlite" or self._pg_conn is None:
|
|
327
|
+
return {"sqlite": True}
|
|
328
|
+
cur = self._pg_conn.cursor()
|
|
329
|
+
if layer:
|
|
330
|
+
cur.execute("SELECT layer, COUNT(*) FROM entries WHERE layer = %s GROUP BY layer", (layer,))
|
|
331
|
+
else:
|
|
332
|
+
cur.execute("SELECT layer, COUNT(*) FROM entries GROUP BY layer ORDER BY layer")
|
|
333
|
+
return {r[0]: r[1] for r in cur.fetchall()}
|
|
334
|
+
|
|
335
|
+
def graph_query(self, entity_label: str, depth: int = 2) -> dict:
|
|
336
|
+
if self.mode == "sqlite" or self._pg_conn is None:
|
|
337
|
+
return {"nodes": [], "edges": []}
|
|
338
|
+
cur = self._pg_conn.cursor()
|
|
339
|
+
cur.execute("SELECT id, label, entity_type FROM entities WHERE label = %s", (entity_label,))
|
|
340
|
+
start = cur.fetchone()
|
|
341
|
+
if not start:
|
|
342
|
+
return {"nodes": [], "edges": []}
|
|
343
|
+
|
|
344
|
+
nodes = {start[0]: {"id": start[0], "label": start[1], "type": start[2]}}
|
|
345
|
+
edges = []
|
|
346
|
+
visited = {start[0]}
|
|
347
|
+
current = {start[0]}
|
|
348
|
+
|
|
349
|
+
for _ in range(depth):
|
|
350
|
+
if not current:
|
|
351
|
+
break
|
|
352
|
+
placeholders = ", ".join(str(i) for i in current)
|
|
353
|
+
cur.execute(
|
|
354
|
+
f"""SELECT r.id, r.source_id, r.target_id, r.rel_type,
|
|
355
|
+
s.label as s_label, s.entity_type as s_type,
|
|
356
|
+
t.label as t_label, t.entity_type as t_type
|
|
357
|
+
FROM relations r
|
|
358
|
+
JOIN entities s ON r.source_id = s.id
|
|
359
|
+
JOIN entities t ON r.target_id = t.id
|
|
360
|
+
WHERE r.source_id IN ({placeholders})
|
|
361
|
+
OR r.target_id IN ({placeholders})"""
|
|
362
|
+
)
|
|
363
|
+
new_ids = set()
|
|
364
|
+
for r in cur.fetchall():
|
|
365
|
+
edges.append({"id": r[0], "source": r[1], "target": r[2], "type": r[3]})
|
|
366
|
+
if r[1] not in visited:
|
|
367
|
+
nodes[r[1]] = {"id": r[1], "label": r[4], "type": r[5]}
|
|
368
|
+
new_ids.add(r[1])
|
|
369
|
+
visited.add(r[1])
|
|
370
|
+
if r[2] not in visited:
|
|
371
|
+
nodes[r[2]] = {"id": r[2], "label": r[6], "type": r[7]}
|
|
372
|
+
new_ids.add(r[2])
|
|
373
|
+
visited.add(r[2])
|
|
374
|
+
current = new_ids
|
|
375
|
+
return {"nodes": list(nodes.values()), "edges": edges}
|
|
376
|
+
|
|
377
|
+
def health_check(self) -> dict:
|
|
378
|
+
result = {
|
|
379
|
+
"mode": self.mode,
|
|
380
|
+
"pg_available": self._pg_conn is not None and self.available,
|
|
381
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
382
|
+
}
|
|
383
|
+
try:
|
|
384
|
+
result["counts"] = self.get_layer_count()
|
|
385
|
+
except Exception as e:
|
|
386
|
+
result["counts_error"] = str(e)
|
|
387
|
+
try:
|
|
388
|
+
test = self.insert(
|
|
389
|
+
layer="P6", type="health_check",
|
|
390
|
+
content=f"[HEALTH_CHECK] {time.time()}",
|
|
391
|
+
who="system:health"
|
|
392
|
+
)
|
|
393
|
+
result["write_test"] = "PASS"
|
|
394
|
+
if self._pg_conn:
|
|
395
|
+
cur = self._pg_conn.cursor()
|
|
396
|
+
cur.execute("DELETE FROM entries WHERE id = %s", (test["id"],))
|
|
397
|
+
cur.close()
|
|
398
|
+
except Exception as e:
|
|
399
|
+
result["write_test"] = f"FAIL: {e}"
|
|
400
|
+
return result
|
|
401
|
+
|
|
402
|
+
def close(self):
|
|
403
|
+
if self._pg_conn:
|
|
404
|
+
self._pg_conn.close()
|
|
405
|
+
if self._sqlite_conn:
|
|
406
|
+
self._sqlite_conn.close()
|
|
407
|
+
|
|
408
|
+
def __enter__(self):
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
def __exit__(self, *args):
|
|
412
|
+
self.close()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Memory business logic — ranked recall, serendipity, TTL management."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import random
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
_SERENDIPITY_CHANCE = 0.05
|
|
8
|
+
_DEFAULT_TTL = {"fact": 90, "decision": 180, "pattern": 30, "incident": 365}
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def recall_ranked(db, query: str, limit: int = 10, layers: list = None,
|
|
14
|
+
serendipity: bool = True) -> list:
|
|
15
|
+
"""Recall with confidence-weighted ranking + serendipity.
|
|
16
|
+
|
|
17
|
+
Falls back to standard search when Pg is unavailable (SQLite mode).
|
|
18
|
+
"""
|
|
19
|
+
has_pg = getattr(db, '_pg_conn', None) is not None
|
|
20
|
+
|
|
21
|
+
if not has_pg:
|
|
22
|
+
results = db.search(query, layers=layers or ["P5", "P6"], limit=limit)
|
|
23
|
+
return list(results) if results else []
|
|
24
|
+
|
|
25
|
+
cur = db._pg_conn.cursor()
|
|
26
|
+
|
|
27
|
+
where_parts = []
|
|
28
|
+
params = []
|
|
29
|
+
if layers:
|
|
30
|
+
placeholders = ", ".join("%s" for _ in layers)
|
|
31
|
+
where_parts.append(f"layer IN ({placeholders})")
|
|
32
|
+
params.extend(layers)
|
|
33
|
+
|
|
34
|
+
if query.strip():
|
|
35
|
+
where_parts.append("tsv @@ plainto_tsquery('english', %s)")
|
|
36
|
+
params.append(query)
|
|
37
|
+
|
|
38
|
+
where_sql = " AND ".join(where_parts) if where_parts else "TRUE"
|
|
39
|
+
ts_query = query.strip() or "."
|
|
40
|
+
rank_expr = "ts_rank(tsv, plainto_tsquery('english', %s))"
|
|
41
|
+
|
|
42
|
+
sql = f"""
|
|
43
|
+
SELECT id, content, type, layer, confidence, ttl_days,
|
|
44
|
+
access_count, last_accessed_at, version_id, created_at
|
|
45
|
+
FROM entries
|
|
46
|
+
WHERE {where_sql}
|
|
47
|
+
AND (superseded_by IS NULL)
|
|
48
|
+
ORDER BY
|
|
49
|
+
({rank_expr} * (0.5 + confidence * 0.5) *
|
|
50
|
+
(1.0 + GREATEST(0.0, 1.0 - EXTRACT(EPOCH FROM (NOW() - created_at)) / 86400.0 /
|
|
51
|
+
NULLIF(NULLIF(ttl_days, 0), 0)) * 0.3)) DESC
|
|
52
|
+
LIMIT %s
|
|
53
|
+
"""
|
|
54
|
+
params.append(ts_query)
|
|
55
|
+
params.append(limit)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
cur.execute(sql, params)
|
|
59
|
+
rows = cur.fetchall()
|
|
60
|
+
except Exception as e:
|
|
61
|
+
logger.warning("Recall ranked query failed, falling back: %s", e)
|
|
62
|
+
rows = db.search(query, layers=layers or ["P5", "P6"], limit=limit)
|
|
63
|
+
rows = list(rows) if rows else []
|
|
64
|
+
|
|
65
|
+
seen_ids = []
|
|
66
|
+
results = []
|
|
67
|
+
for row in rows:
|
|
68
|
+
if isinstance(row, dict):
|
|
69
|
+
results.append(row)
|
|
70
|
+
if "id" in row:
|
|
71
|
+
seen_ids.append(row["id"])
|
|
72
|
+
continue
|
|
73
|
+
entry = {
|
|
74
|
+
"id": row[0], "content": row[1], "type": row[2],
|
|
75
|
+
"layer": row[3], "confidence": row[4], "ttl_days": row[5],
|
|
76
|
+
"access_count": row[6], "version_id": row[8],
|
|
77
|
+
}
|
|
78
|
+
seen_ids.append(row[0])
|
|
79
|
+
results.append(entry)
|
|
80
|
+
|
|
81
|
+
if seen_ids and has_pg:
|
|
82
|
+
try:
|
|
83
|
+
cur.execute(
|
|
84
|
+
"UPDATE entries SET access_count = access_count + 1, "
|
|
85
|
+
"last_accessed_at = NOW() WHERE id = ANY(%s)",
|
|
86
|
+
(seen_ids,)
|
|
87
|
+
)
|
|
88
|
+
db._pg_conn.commit()
|
|
89
|
+
except Exception:
|
|
90
|
+
db._pg_conn.rollback()
|
|
91
|
+
|
|
92
|
+
if serendipity and random.random() < _SERENDIPITY_CHANCE and seen_ids:
|
|
93
|
+
try:
|
|
94
|
+
cur.execute(
|
|
95
|
+
"SELECT id, content, type, layer, confidence, ttl_days, "
|
|
96
|
+
"access_count, version_id FROM entries "
|
|
97
|
+
"WHERE id != ALL(%s) AND (superseded_by IS NULL) "
|
|
98
|
+
"AND (access_count = 0 OR last_accessed_at IS NULL "
|
|
99
|
+
"OR last_accessed_at < NOW() - INTERVAL '60 days') "
|
|
100
|
+
"ORDER BY RANDOM() LIMIT 1",
|
|
101
|
+
(seen_ids,)
|
|
102
|
+
)
|
|
103
|
+
wild = cur.fetchone()
|
|
104
|
+
if wild:
|
|
105
|
+
results.append({
|
|
106
|
+
"id": wild[0], "content": wild[1], "type": wild[2],
|
|
107
|
+
"layer": wild[3], "confidence": wild[4], "ttl_days": wild[5],
|
|
108
|
+
"access_count": wild[6], "version_id": wild[7],
|
|
109
|
+
"_serendipity": True,
|
|
110
|
+
})
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
return results
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Ontology type hierarchy and relation constraints.
|
|
2
|
+
|
|
3
|
+
Reusable across both PostgreSQL and SQLite backends.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
TYPE_HIERARCHY = {
|
|
7
|
+
"artifact": None,
|
|
8
|
+
"doc": "artifact",
|
|
9
|
+
"code": "artifact",
|
|
10
|
+
"project": "artifact",
|
|
11
|
+
"agent": None,
|
|
12
|
+
"persona": "agent",
|
|
13
|
+
"tool": "agent",
|
|
14
|
+
"script": "agent",
|
|
15
|
+
"skill": "agent",
|
|
16
|
+
"decision": None,
|
|
17
|
+
"pattern": "decision",
|
|
18
|
+
"preference": "decision",
|
|
19
|
+
"event": None,
|
|
20
|
+
"incident": "event",
|
|
21
|
+
"session": "event",
|
|
22
|
+
"knowledge": None,
|
|
23
|
+
"concept": "knowledge",
|
|
24
|
+
"paper": "knowledge",
|
|
25
|
+
"reference": "knowledge",
|
|
26
|
+
"meta": None,
|
|
27
|
+
"category": "meta",
|
|
28
|
+
"_task": "meta",
|
|
29
|
+
"fact": "meta",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
RELATION_CONSTRAINTS = {
|
|
33
|
+
"depends_on": (None, ("tool", "script", "skill")),
|
|
34
|
+
"fixed_by": (("incident",), ("pattern", "decision")),
|
|
35
|
+
"caused": (("decision", "pattern"), ("incident",)),
|
|
36
|
+
"led_to": (("decision", "pattern", "preference"), ("decision", "pattern", "preference")),
|
|
37
|
+
"implements": (("script",), ("pattern", "decision")),
|
|
38
|
+
"contradicts": (("decision", "pattern", "preference"), ("decision", "pattern", "preference")),
|
|
39
|
+
"cites": (("paper",), ("paper",)),
|
|
40
|
+
"references": None,
|
|
41
|
+
"relates_to": None,
|
|
42
|
+
"subtype_of": None,
|
|
43
|
+
"belongs_to": None,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
ONTOLOGY = {
|
|
47
|
+
"type_hierarchy": TYPE_HIERARCHY,
|
|
48
|
+
"relation_constraints": RELATION_CONSTRAINTS,
|
|
49
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MCP Agent Memory Server — PgVector-backed with SQLite fallback.
|
|
3
|
+
|
|
4
|
+
Tools:
|
|
5
|
+
knowledge_remember — store a fact
|
|
6
|
+
knowledge_recall — search with confidence ranking + serendipity
|
|
7
|
+
knowledge_forget — supersede (soft-delete)
|
|
8
|
+
knowledge_update — update by ID (bumps version)
|
|
9
|
+
knowledge_memory-stats — stats
|
|
10
|
+
knowledge_snapshot-create — snapshot current entries
|
|
11
|
+
knowledge_snapshot-rollback — rollback to a snapshot
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
from mcp.server import Server
|
|
19
|
+
from mcp.server.stdio import stdio_server
|
|
20
|
+
from mcp.types import Tool, TextContent
|
|
21
|
+
|
|
22
|
+
from p_layer.core.db import KnowledgeDB
|
|
23
|
+
from p_layer.core.memory import recall_ranked, _DEFAULT_TTL
|
|
24
|
+
|
|
25
|
+
server = Server("knowledge-system")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_db():
|
|
29
|
+
return KnowledgeDB()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _has_pg(db) -> bool:
|
|
33
|
+
return db._pg_conn is not None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@server.list_tools()
|
|
37
|
+
async def list_tools() -> list[Tool]:
|
|
38
|
+
return [
|
|
39
|
+
Tool(
|
|
40
|
+
name="knowledge_remember",
|
|
41
|
+
description="Store a fact, decision, pattern, or incident into persistent memory",
|
|
42
|
+
inputSchema={
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"fact": {"type": "string", "description": "The content to remember"},
|
|
46
|
+
"type": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"enum": ["fact", "decision", "pattern", "incident"],
|
|
49
|
+
"default": "fact",
|
|
50
|
+
},
|
|
51
|
+
"confidence": {
|
|
52
|
+
"type": "number", "default": 1.0,
|
|
53
|
+
"description": "Confidence score 0.0-1.0",
|
|
54
|
+
},
|
|
55
|
+
"ttl_days": {
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"description": "Days before deprioritization (default: fact=90, decision=180, pattern=30, incident=365)",
|
|
58
|
+
},
|
|
59
|
+
"version_id": {
|
|
60
|
+
"type": "string", "default": "latest",
|
|
61
|
+
"description": "Version label for grouping",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"required": ["fact"],
|
|
65
|
+
},
|
|
66
|
+
),
|
|
67
|
+
Tool(
|
|
68
|
+
name="knowledge_recall",
|
|
69
|
+
description="Search persistent memory — ranked by relevance, confidence, and freshness",
|
|
70
|
+
inputSchema={
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"query": {"type": "string", "description": "Search query"},
|
|
74
|
+
"limit": {"type": "integer", "default": 10},
|
|
75
|
+
"serendipity": {
|
|
76
|
+
"type": "boolean", "default": True,
|
|
77
|
+
"description": "Include a wildcard low-ranked entry",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
"required": ["query"],
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
Tool(
|
|
84
|
+
name="knowledge_forget",
|
|
85
|
+
description="Supersede a memory entry (soft-delete)",
|
|
86
|
+
inputSchema={
|
|
87
|
+
"type": "object",
|
|
88
|
+
"properties": {
|
|
89
|
+
"id": {"type": "integer", "description": "Entry ID to forget"},
|
|
90
|
+
"reason": {"type": "string", "description": "Optional reason"},
|
|
91
|
+
},
|
|
92
|
+
"required": ["id"],
|
|
93
|
+
},
|
|
94
|
+
),
|
|
95
|
+
Tool(
|
|
96
|
+
name="knowledge_memory-stats",
|
|
97
|
+
description="Show memory database statistics",
|
|
98
|
+
inputSchema={"type": "object", "properties": {}},
|
|
99
|
+
),
|
|
100
|
+
Tool(
|
|
101
|
+
name="knowledge_update",
|
|
102
|
+
description="Update a memory entry — bumps version, old entry is superseded",
|
|
103
|
+
inputSchema={
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"id": {"type": "integer", "description": "Entry ID"},
|
|
107
|
+
"fact": {"type": "string", "description": "New content"},
|
|
108
|
+
"type": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"enum": ["fact", "decision", "pattern", "incident"],
|
|
111
|
+
},
|
|
112
|
+
"confidence": {"type": "number", "description": "Updated confidence 0.0-1.0"},
|
|
113
|
+
},
|
|
114
|
+
"required": ["id"],
|
|
115
|
+
},
|
|
116
|
+
),
|
|
117
|
+
Tool(
|
|
118
|
+
name="knowledge_snapshot-create",
|
|
119
|
+
description="Create a snapshot of current entries under a version label",
|
|
120
|
+
inputSchema={
|
|
121
|
+
"type": "object",
|
|
122
|
+
"properties": {
|
|
123
|
+
"version_id": {"type": "string", "description": "e.g. 'v1', 'v2.1'"},
|
|
124
|
+
"label": {"type": "string", "description": "Human-readable description"},
|
|
125
|
+
},
|
|
126
|
+
"required": ["version_id"],
|
|
127
|
+
},
|
|
128
|
+
),
|
|
129
|
+
Tool(
|
|
130
|
+
name="knowledge_snapshot-rollback",
|
|
131
|
+
description="Rollback to a snapshot — marks newer entries as superseded",
|
|
132
|
+
inputSchema={
|
|
133
|
+
"type": "object",
|
|
134
|
+
"properties": {
|
|
135
|
+
"version_id": {"type": "string", "description": "Snapshot version to rollback to"},
|
|
136
|
+
},
|
|
137
|
+
"required": ["version_id"],
|
|
138
|
+
},
|
|
139
|
+
),
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@server.call_tool()
|
|
144
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
145
|
+
db = get_db()
|
|
146
|
+
|
|
147
|
+
if name == "knowledge_remember":
|
|
148
|
+
fact = arguments["fact"]
|
|
149
|
+
typ = arguments.get("type", "fact")
|
|
150
|
+
confidence = max(0.0, min(1.0, float(arguments.get("confidence", 1.0))))
|
|
151
|
+
ttl = arguments.get("ttl_days", _DEFAULT_TTL.get(typ, 90))
|
|
152
|
+
version_id = arguments.get("version_id", "latest")
|
|
153
|
+
|
|
154
|
+
result = db.insert(
|
|
155
|
+
layer="P6", type=typ, content=fact,
|
|
156
|
+
who="tool:knowledge-system",
|
|
157
|
+
source="knowledge_remember",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if _has_pg(db) and isinstance(result, dict) and result.get("id"):
|
|
161
|
+
try:
|
|
162
|
+
cur = db._pg_conn.cursor()
|
|
163
|
+
cur.execute(
|
|
164
|
+
"UPDATE entries SET confidence = %s, ttl_days = %s, version_id = %s WHERE id = %s",
|
|
165
|
+
(confidence, ttl, version_id, result["id"]),
|
|
166
|
+
)
|
|
167
|
+
db._pg_conn.commit()
|
|
168
|
+
except Exception:
|
|
169
|
+
db._pg_conn.rollback()
|
|
170
|
+
|
|
171
|
+
return [TextContent(type="text", text=str(result))]
|
|
172
|
+
|
|
173
|
+
elif name == "knowledge_recall":
|
|
174
|
+
query = arguments["query"]
|
|
175
|
+
limit = int(arguments.get("limit", 10))
|
|
176
|
+
serendipity = arguments.get("serendipity", True)
|
|
177
|
+
|
|
178
|
+
if not query.strip():
|
|
179
|
+
results = db.search("", layers=["P5", "P6"], limit=limit)
|
|
180
|
+
else:
|
|
181
|
+
results = recall_ranked(db, query, limit=limit, layers=["P5", "P6"], serendipity=serendipity)
|
|
182
|
+
return [TextContent(type="text", text=str(results))]
|
|
183
|
+
|
|
184
|
+
elif name == "knowledge_forget":
|
|
185
|
+
mem_id = int(arguments["id"])
|
|
186
|
+
reason = arguments.get("reason", "")
|
|
187
|
+
|
|
188
|
+
if _has_pg(db):
|
|
189
|
+
cur = db._pg_conn.cursor()
|
|
190
|
+
cur.execute(
|
|
191
|
+
"UPDATE entries SET superseded_by = id, confidence = GREATEST(confidence, 0.0), "
|
|
192
|
+
"updated_at = NOW() WHERE id = %s",
|
|
193
|
+
(mem_id,),
|
|
194
|
+
)
|
|
195
|
+
db._pg_conn.commit()
|
|
196
|
+
msg = {"superseded": cur.rowcount > 0, "id": mem_id}
|
|
197
|
+
if reason:
|
|
198
|
+
msg["reason"] = reason
|
|
199
|
+
return [TextContent(type="text", text=str(msg))]
|
|
200
|
+
|
|
201
|
+
cur = db._connect_sqlite().execute(
|
|
202
|
+
"DELETE FROM memory_fts WHERE rowid = ?", (mem_id,)
|
|
203
|
+
)
|
|
204
|
+
return [TextContent(type="text", text=str({"deleted": cur.rowcount > 0}))]
|
|
205
|
+
|
|
206
|
+
elif name == "knowledge_memory-stats":
|
|
207
|
+
counts = db.get_layer_count()
|
|
208
|
+
if _has_pg(db):
|
|
209
|
+
cur = db._pg_conn.cursor()
|
|
210
|
+
cur.execute("SELECT COUNT(*) FROM entries WHERE superseded_by IS NULL")
|
|
211
|
+
active = cur.fetchone()[0]
|
|
212
|
+
cur.execute("SELECT COUNT(*) FROM entries WHERE superseded_by IS NOT NULL")
|
|
213
|
+
superseded = cur.fetchone()[0]
|
|
214
|
+
else:
|
|
215
|
+
active = sum(counts.values())
|
|
216
|
+
superseded = 0
|
|
217
|
+
return [TextContent(type="text", text=str({
|
|
218
|
+
"total": sum(counts.values()) if isinstance(counts, dict) else 0,
|
|
219
|
+
"active": active,
|
|
220
|
+
"superseded": superseded,
|
|
221
|
+
"byLayer": counts,
|
|
222
|
+
}))]
|
|
223
|
+
|
|
224
|
+
elif name == "knowledge_update":
|
|
225
|
+
mem_id = int(arguments["id"])
|
|
226
|
+
fact = arguments.get("fact")
|
|
227
|
+
typ = arguments.get("type")
|
|
228
|
+
confidence = arguments.get("confidence")
|
|
229
|
+
|
|
230
|
+
if _has_pg(db):
|
|
231
|
+
cur = db._pg_conn.cursor()
|
|
232
|
+
if fact:
|
|
233
|
+
cur.execute(
|
|
234
|
+
"UPDATE entries SET superseded_by = id, updated_at = NOW() WHERE id = %s",
|
|
235
|
+
(mem_id,),
|
|
236
|
+
)
|
|
237
|
+
new_result = db.insert(
|
|
238
|
+
layer="P6", type=typ or "fact", content=fact,
|
|
239
|
+
who="tool:knowledge-system", source="knowledge_update",
|
|
240
|
+
)
|
|
241
|
+
if isinstance(new_result, dict) and new_result.get("id"):
|
|
242
|
+
cur.execute(
|
|
243
|
+
"UPDATE entries SET version_id = 'v2' WHERE id = %s",
|
|
244
|
+
(new_result["id"],),
|
|
245
|
+
)
|
|
246
|
+
db._pg_conn.commit()
|
|
247
|
+
return [TextContent(type="text", text=str({
|
|
248
|
+
"updated": True, "superseded_id": mem_id,
|
|
249
|
+
"new_id": new_result.get("id") if isinstance(new_result, dict) else None,
|
|
250
|
+
}))]
|
|
251
|
+
|
|
252
|
+
updates = []
|
|
253
|
+
params = []
|
|
254
|
+
if confidence is not None:
|
|
255
|
+
updates.append("confidence = %s")
|
|
256
|
+
params.append(max(0.0, min(1.0, float(confidence))))
|
|
257
|
+
if typ:
|
|
258
|
+
updates.append("type = %s")
|
|
259
|
+
params.append(typ)
|
|
260
|
+
if updates:
|
|
261
|
+
params.append(mem_id)
|
|
262
|
+
cur.execute(
|
|
263
|
+
f"UPDATE entries SET {', '.join(updates)}, updated_at = NOW() WHERE id = %s",
|
|
264
|
+
params,
|
|
265
|
+
)
|
|
266
|
+
db._pg_conn.commit()
|
|
267
|
+
return [TextContent(type="text", text=str({"updated": cur.rowcount > 0}))]
|
|
268
|
+
|
|
269
|
+
return [TextContent(type="text", text=str({"updated": False}))]
|
|
270
|
+
|
|
271
|
+
elif name == "knowledge_snapshot-create":
|
|
272
|
+
version_id = arguments["version_id"]
|
|
273
|
+
label = arguments.get("label", "")
|
|
274
|
+
|
|
275
|
+
if not _has_pg(db):
|
|
276
|
+
return [TextContent(type="text", text=str({"error": "PgVector required for snapshots"}))]
|
|
277
|
+
|
|
278
|
+
cur = db._pg_conn.cursor()
|
|
279
|
+
cur.execute("SELECT ARRAY_AGG(id) FROM entries WHERE superseded_by IS NULL")
|
|
280
|
+
entry_ids = cur.fetchone()[0] or []
|
|
281
|
+
|
|
282
|
+
cur.execute(
|
|
283
|
+
"INSERT INTO memory_snapshots (version_id, label, entry_ids) "
|
|
284
|
+
"VALUES (%s, %s, %s) "
|
|
285
|
+
"ON CONFLICT (version_id) DO UPDATE SET label = EXCLUDED.label, "
|
|
286
|
+
"entry_ids = EXCLUDED.entry_ids, created_at = NOW()",
|
|
287
|
+
(version_id, label, entry_ids),
|
|
288
|
+
)
|
|
289
|
+
db._pg_conn.commit()
|
|
290
|
+
return [TextContent(type="text", text=str({
|
|
291
|
+
"version_id": version_id, "entries_snapshot": len(entry_ids),
|
|
292
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
293
|
+
}))]
|
|
294
|
+
|
|
295
|
+
elif name == "knowledge_snapshot-rollback":
|
|
296
|
+
version_id = arguments["version_id"]
|
|
297
|
+
|
|
298
|
+
if not _has_pg(db):
|
|
299
|
+
return [TextContent(type="text", text=str({"error": "PgVector required for snapshots"}))]
|
|
300
|
+
|
|
301
|
+
cur = db._pg_conn.cursor()
|
|
302
|
+
cur.execute(
|
|
303
|
+
"SELECT entry_ids, created_at FROM memory_snapshots WHERE version_id = %s",
|
|
304
|
+
(version_id,),
|
|
305
|
+
)
|
|
306
|
+
row = cur.fetchone()
|
|
307
|
+
if not row:
|
|
308
|
+
return [TextContent(type="text", text=str({"error": f"Snapshot '{version_id}' not found"}))]
|
|
309
|
+
|
|
310
|
+
snapshot_ids, snapshot_at = row
|
|
311
|
+
cur.execute(
|
|
312
|
+
"UPDATE entries SET superseded_by = id, updated_at = NOW() "
|
|
313
|
+
"WHERE id != ALL(%s) AND created_at > %s AND superseded_by IS NULL",
|
|
314
|
+
(snapshot_ids, snapshot_at),
|
|
315
|
+
)
|
|
316
|
+
affected = cur.rowcount
|
|
317
|
+
db._pg_conn.commit()
|
|
318
|
+
|
|
319
|
+
return [TextContent(type="text", text=str({
|
|
320
|
+
"version_id": version_id, "entries_rolled_forward": affected,
|
|
321
|
+
"snapshot_entries": len(snapshot_ids),
|
|
322
|
+
"note": "Entries superseded, not deleted. Recall ranking deprioritizes them.",
|
|
323
|
+
}))]
|
|
324
|
+
|
|
325
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def main():
|
|
329
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
330
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
if __name__ == "__main__":
|
|
334
|
+
import asyncio
|
|
335
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: p-layers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 7 governance layers for AI agent memory — P0-P6 with KnowledgeDB, MCP server, and wiki-compile
|
|
5
|
+
Project-URL: Home, https://github.com/humanerd-drew/p-layer
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: mcp>=1.0
|
|
9
|
+
Provides-Extra: pg
|
|
10
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == "pg"
|
|
11
|
+
Requires-Dist: pgvector>=0.3; extra == "pg"
|
|
12
|
+
Requires-Dist: numpy>=1.24; extra == "pg"
|
|
13
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
p_layer/__init__.py
|
|
5
|
+
p_layer/core/__init__.py
|
|
6
|
+
p_layer/core/db.py
|
|
7
|
+
p_layer/core/memory.py
|
|
8
|
+
p_layer/core/ontology.py
|
|
9
|
+
p_layer/mcp/__init__.py
|
|
10
|
+
p_layer/mcp/server.py
|
|
11
|
+
p_layers.egg-info/PKG-INFO
|
|
12
|
+
p_layers.egg-info/SOURCES.txt
|
|
13
|
+
p_layers.egg-info/dependency_links.txt
|
|
14
|
+
p_layers.egg-info/entry_points.txt
|
|
15
|
+
p_layers.egg-info/requires.txt
|
|
16
|
+
p_layers.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
p_layer
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "p-layers"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "7 governance layers for AI agent memory — P0-P6 with KnowledgeDB, MCP server, and wiki-compile"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"mcp>=1.0",
|
|
8
|
+
]
|
|
9
|
+
[project.optional-dependencies]
|
|
10
|
+
pg = [
|
|
11
|
+
"psycopg2-binary>=2.9",
|
|
12
|
+
"pgvector>=0.3",
|
|
13
|
+
"numpy>=1.24",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Home = "https://github.com/humanerd-drew/p-layer"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
p-layer-mcp = "p_layer.mcp.server:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["setuptools>=68"]
|
|
24
|
+
build-backend = "setuptools.build_meta"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
include = ["p_layer*"]
|
p_layers-0.1.0/setup.cfg
ADDED