dreaming-memory 0.2.0__py3-none-any.whl
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.
- dreaming_memory/__init__.py +18 -0
- dreaming_memory/agent_memory.py +161 -0
- dreaming_memory/cli.py +262 -0
- dreaming_memory/config.py +130 -0
- dreaming_memory/dashboard.py +150 -0
- dreaming_memory/integrations/__init__.py +13 -0
- dreaming_memory/integrations/cloudflare.py +62 -0
- dreaming_memory/integrations/linear.py +301 -0
- dreaming_memory/integrations/notion.py +142 -0
- dreaming_memory/integrations/slack.py +87 -0
- dreaming_memory/observability/__init__.py +4 -0
- dreaming_memory/observability/sentry.py +58 -0
- dreaming_memory/py.typed +1 -0
- dreaming_memory/semantic/__init__.py +3 -0
- dreaming_memory/semantic/lancedb.py +74 -0
- dreaming_memory/session.py +86 -0
- dreaming_memory/store/__init__.py +3 -0
- dreaming_memory/store/migrations/schema_v1.sql +39 -0
- dreaming_memory/store/migrations/schema_v2.sql +13 -0
- dreaming_memory/store/migrations/schema_v3.sql +47 -0
- dreaming_memory/store/oci.py +51 -0
- dreaming_memory/store/postgres.py +282 -0
- dreaming_memory/store/schema.sql +39 -0
- dreaming_memory/triage.py +175 -0
- dreaming_memory/types.py +64 -0
- dreaming_memory-0.2.0.dist-info/METADATA +110 -0
- dreaming_memory-0.2.0.dist-info/RECORD +29 -0
- dreaming_memory-0.2.0.dist-info/WHEEL +4 -0
- dreaming_memory-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Agent-agnostic memory extension — Postgres-backed agent memory."""
|
|
2
|
+
|
|
3
|
+
from dreaming_memory.agent_memory import AgentMemory
|
|
4
|
+
from dreaming_memory.config import FleetConfig
|
|
5
|
+
from dreaming_memory.session import SessionContext
|
|
6
|
+
from dreaming_memory.types import MemoryRecord, MemorySource, MemoryType, SessionType
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AgentMemory",
|
|
10
|
+
"FleetConfig",
|
|
11
|
+
"MemoryRecord",
|
|
12
|
+
"MemorySource",
|
|
13
|
+
"MemoryType",
|
|
14
|
+
"SessionContext",
|
|
15
|
+
"SessionType",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""High-level facade for agents — Postgres SSOT + optional semantic + integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from dreaming_memory.config import FleetConfig
|
|
9
|
+
from dreaming_memory.integrations.cloudflare import CloudflareR2
|
|
10
|
+
from dreaming_memory.integrations.linear import LinearMemoryBridge
|
|
11
|
+
from dreaming_memory.integrations.notion import NotionMemoryBridge
|
|
12
|
+
from dreaming_memory.observability import sentry
|
|
13
|
+
from dreaming_memory.semantic.lancedb import SemanticMemoryStore
|
|
14
|
+
from dreaming_memory.session import SessionContext
|
|
15
|
+
from dreaming_memory.store.postgres import AgentMemoryStore
|
|
16
|
+
from dreaming_memory.types import MemoryRecord, MemorySource, MemoryType, SessionType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentMemory:
|
|
20
|
+
"""
|
|
21
|
+
Main entry point for agent memory within cursor-dreaming-sdk.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
memory = AgentMemory()
|
|
25
|
+
memory.ensure_schema()
|
|
26
|
+
ctx = SessionContext.for_dream_eval("2026-06-15T09-00-00Z")
|
|
27
|
+
memory.remember(ctx, MemoryType.OBSERVATION, {"note": "faithfulness 0.63"}, source=MemorySource.SDK)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
store: AgentMemoryStore | None = None,
|
|
33
|
+
semantic: SemanticMemoryStore | None = None,
|
|
34
|
+
linear: LinearMemoryBridge | None = None,
|
|
35
|
+
notion: NotionMemoryBridge | None = None,
|
|
36
|
+
config: FleetConfig | None = None,
|
|
37
|
+
enable_sentry: bool = True,
|
|
38
|
+
) -> None:
|
|
39
|
+
self.config = config or FleetConfig.load()
|
|
40
|
+
self.store = store or AgentMemoryStore(self.config.database_url)
|
|
41
|
+
self.semantic = semantic
|
|
42
|
+
self._linear = linear
|
|
43
|
+
self._notion = notion
|
|
44
|
+
self._cloudflare: CloudflareR2 | None = None
|
|
45
|
+
if enable_sentry:
|
|
46
|
+
sentry.init_sentry(self.config)
|
|
47
|
+
|
|
48
|
+
def close(self) -> None:
|
|
49
|
+
self.store.close()
|
|
50
|
+
|
|
51
|
+
def __enter__(self) -> AgentMemory:
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
def __exit__(self, *exc: Any) -> None:
|
|
55
|
+
self.close()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def cloudflare(self) -> CloudflareR2:
|
|
59
|
+
if self._cloudflare is None:
|
|
60
|
+
self._cloudflare = CloudflareR2(self.config)
|
|
61
|
+
return self._cloudflare
|
|
62
|
+
|
|
63
|
+
def ensure_schema(self) -> None:
|
|
64
|
+
self.store.ensure_schema()
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def linear(self) -> LinearMemoryBridge:
|
|
68
|
+
if self._linear is None:
|
|
69
|
+
self._linear = LinearMemoryBridge(self.store)
|
|
70
|
+
return self._linear
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def notion(self) -> NotionMemoryBridge:
|
|
74
|
+
if self._notion is None:
|
|
75
|
+
self._notion = NotionMemoryBridge(self.store)
|
|
76
|
+
return self._notion
|
|
77
|
+
|
|
78
|
+
def remember(
|
|
79
|
+
self,
|
|
80
|
+
session: SessionContext,
|
|
81
|
+
memory_type: MemoryType,
|
|
82
|
+
content: dict[str, Any],
|
|
83
|
+
*,
|
|
84
|
+
source: MemorySource = MemorySource.SDK,
|
|
85
|
+
metadata: dict[str, Any] | None = None,
|
|
86
|
+
) -> MemoryRecord:
|
|
87
|
+
record = MemoryRecord(
|
|
88
|
+
agent_id=session.agent_id,
|
|
89
|
+
session_id=session.session_id,
|
|
90
|
+
session_type=session.session_type,
|
|
91
|
+
memory_type=memory_type,
|
|
92
|
+
content=content,
|
|
93
|
+
source=source,
|
|
94
|
+
metadata={**(session.metadata or {}), **(metadata or {})},
|
|
95
|
+
)
|
|
96
|
+
sentry.breadcrumb(
|
|
97
|
+
f"remember {memory_type.value}",
|
|
98
|
+
data={"session_type": session.session_type.value, "source": source.value},
|
|
99
|
+
)
|
|
100
|
+
return self.store.write(record)
|
|
101
|
+
|
|
102
|
+
def recall(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
agent_id: str | None = None,
|
|
106
|
+
session_id: str | None = None,
|
|
107
|
+
session_type: SessionType | None = None,
|
|
108
|
+
memory_type: MemoryType | None = None,
|
|
109
|
+
source: MemorySource | None = None,
|
|
110
|
+
limit: int = 50,
|
|
111
|
+
) -> list[MemoryRecord]:
|
|
112
|
+
return self.store.query(
|
|
113
|
+
agent_id=agent_id,
|
|
114
|
+
session_id=session_id,
|
|
115
|
+
session_type=session_type,
|
|
116
|
+
memory_type=memory_type,
|
|
117
|
+
source=source,
|
|
118
|
+
limit=limit,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def recall_session(self, session: SessionContext, limit: int = 50) -> list[MemoryRecord]:
|
|
122
|
+
return self.recall(
|
|
123
|
+
agent_id=session.agent_id,
|
|
124
|
+
session_id=session.session_id,
|
|
125
|
+
session_type=session.session_type,
|
|
126
|
+
limit=limit,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def index_semantic(
|
|
130
|
+
self,
|
|
131
|
+
record: MemoryRecord,
|
|
132
|
+
text: str,
|
|
133
|
+
vector: list[float],
|
|
134
|
+
) -> MemoryRecord | None:
|
|
135
|
+
if self.semantic is None:
|
|
136
|
+
raise RuntimeError("semantic store not configured — pass SemanticMemoryStore() to AgentMemory")
|
|
137
|
+
if not self.semantic.enabled:
|
|
138
|
+
return None
|
|
139
|
+
if record.id is None:
|
|
140
|
+
raise ValueError("record must have an id before indexing into semantic store")
|
|
141
|
+
self.semantic.index_memory(
|
|
142
|
+
record.id, text, vector, metadata={"source": record.source.value}
|
|
143
|
+
)
|
|
144
|
+
ref = MemoryRecord(
|
|
145
|
+
agent_id=record.agent_id,
|
|
146
|
+
session_id=record.session_id,
|
|
147
|
+
session_type=record.session_type,
|
|
148
|
+
memory_type=MemoryType.EMBEDDING_REF,
|
|
149
|
+
source=MemorySource.SDK,
|
|
150
|
+
content={"text_preview": text[:500], "parent_id": str(record.id)},
|
|
151
|
+
metadata={"lance_table": self.semantic.table_name},
|
|
152
|
+
)
|
|
153
|
+
return self.store.write(ref)
|
|
154
|
+
|
|
155
|
+
def search_semantic(self, vector: list[float], limit: int = 10) -> list[dict[str, Any]]:
|
|
156
|
+
if self.semantic is None or not self.semantic.enabled:
|
|
157
|
+
return []
|
|
158
|
+
return self.semantic.search(vector, limit=limit)
|
|
159
|
+
|
|
160
|
+
def get(self, memory_id: UUID) -> MemoryRecord | None:
|
|
161
|
+
return self.store.get(memory_id)
|
dreaming_memory/cli.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CLI for agent memory — schema init, query, Linear/Notion ingest."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from dreaming_memory import AgentMemory, FleetConfig, SessionContext
|
|
11
|
+
from dreaming_memory.types import MemoryRecord, MemorySource, MemoryType, SessionType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _print_records(records: list[MemoryRecord]) -> None:
|
|
15
|
+
for r in records:
|
|
16
|
+
print(json.dumps(r.model_dump(mode="json"), default=str))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _enum_value(value: object) -> object:
|
|
20
|
+
"""Return the underlying value for StrEnum members, pass others through."""
|
|
21
|
+
return value.value if hasattr(value, "value") else value
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def render_export_markdown(session_id: str, records: list[MemoryRecord]) -> str:
|
|
25
|
+
"""Render memory records for a session as Markdown (pure, DB-free)."""
|
|
26
|
+
lines: list[str] = [f"# Memory export — {session_id}", ""]
|
|
27
|
+
if not records:
|
|
28
|
+
lines.append("_No memory records found._")
|
|
29
|
+
return "\n".join(lines) + "\n"
|
|
30
|
+
for r in records:
|
|
31
|
+
memory_type = _enum_value(r.memory_type)
|
|
32
|
+
source = _enum_value(r.source)
|
|
33
|
+
created = r.created_at.isoformat() if r.created_at else "unknown"
|
|
34
|
+
lines.append(f"## {memory_type} ({created})")
|
|
35
|
+
lines.append(f"**Source:** {source}")
|
|
36
|
+
lines.append("")
|
|
37
|
+
lines.append("```json")
|
|
38
|
+
lines.append(json.dumps(r.content, indent=2, default=str, ensure_ascii=False))
|
|
39
|
+
lines.append("```")
|
|
40
|
+
lines.append("")
|
|
41
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _doctor() -> None:
|
|
45
|
+
"""Print config presence + Postgres/Linear/Notion connectivity (no secret values)."""
|
|
46
|
+
import httpx
|
|
47
|
+
import psycopg
|
|
48
|
+
|
|
49
|
+
config = FleetConfig.load()
|
|
50
|
+
status = config.status()
|
|
51
|
+
print(json.dumps({"config": status, "redacted": config.redacted()}, indent=2))
|
|
52
|
+
try:
|
|
53
|
+
memory = AgentMemory(config=config, enable_sentry=False)
|
|
54
|
+
with memory.store._pool.connection() as conn:
|
|
55
|
+
conn.execute("SELECT 1")
|
|
56
|
+
memory.store.close()
|
|
57
|
+
print('{"postgres": "ok"}')
|
|
58
|
+
except psycopg.Error as exc:
|
|
59
|
+
print(json.dumps({"postgres": "error", "detail": str(exc)}))
|
|
60
|
+
|
|
61
|
+
if config.linear_api_key:
|
|
62
|
+
try:
|
|
63
|
+
from dreaming_memory.integrations.linear import LinearClient
|
|
64
|
+
|
|
65
|
+
client = LinearClient(api_key=config.linear_api_key)
|
|
66
|
+
client.gql("query { viewer { id } }")
|
|
67
|
+
print('{"linear": "ok"}')
|
|
68
|
+
except httpx.HTTPError as exc:
|
|
69
|
+
print(json.dumps({"linear": "error", "detail": str(exc)}))
|
|
70
|
+
else:
|
|
71
|
+
print('{"linear": "missing_api_key"}')
|
|
72
|
+
|
|
73
|
+
if config.notion_api_key:
|
|
74
|
+
try:
|
|
75
|
+
from dreaming_memory.integrations.notion import NotionMemoryBridge
|
|
76
|
+
from dreaming_memory.store.postgres import AgentMemoryStore
|
|
77
|
+
|
|
78
|
+
NotionMemoryBridge(AgentMemoryStore(config.database_url))
|
|
79
|
+
print('{"notion": "wired"}')
|
|
80
|
+
except httpx.HTTPError as exc:
|
|
81
|
+
print(json.dumps({"notion": "error", "detail": str(exc)}))
|
|
82
|
+
else:
|
|
83
|
+
print('{"notion": "missing_api_key"}')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
parser = argparse.ArgumentParser(description="cursor-dreaming-sdk agent memory CLI")
|
|
88
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
89
|
+
|
|
90
|
+
sub.add_parser("init", help="Apply Postgres schema")
|
|
91
|
+
sub.add_parser("doctor", help="Show config presence + DB connectivity")
|
|
92
|
+
|
|
93
|
+
p_metrics = sub.add_parser("metrics", help="Print aggregated metrics as JSON")
|
|
94
|
+
p_metrics.add_argument("--days", type=int, default=14)
|
|
95
|
+
|
|
96
|
+
p_triage = sub.add_parser("triage", help="Auto-triage untriaged Linear issues")
|
|
97
|
+
p_triage.add_argument("--state", default=None, help="Filter by workflow state name")
|
|
98
|
+
p_triage.add_argument("--limit", type=int, default=50)
|
|
99
|
+
p_triage.add_argument("--apply", action="store_true", help="Write priority/labels to Linear")
|
|
100
|
+
p_triage.add_argument("--comment", action="store_true", help="Post rationale comment")
|
|
101
|
+
|
|
102
|
+
p_serve = sub.add_parser("serve", help="Run the metrics dashboard")
|
|
103
|
+
p_serve.add_argument("--host", default="0.0.0.0")
|
|
104
|
+
p_serve.add_argument("--port", type=int, default=8787)
|
|
105
|
+
|
|
106
|
+
p_remember = sub.add_parser("remember", help="Write a memory record")
|
|
107
|
+
p_remember.add_argument("--agent", default="default")
|
|
108
|
+
p_remember.add_argument("--session-id", required=True)
|
|
109
|
+
p_remember.add_argument("--session-type", default="generic")
|
|
110
|
+
p_remember.add_argument("--type", dest="memory_type", default="observation")
|
|
111
|
+
p_remember.add_argument("--source", default="sdk")
|
|
112
|
+
p_remember.add_argument("--content", required=True, help="JSON object")
|
|
113
|
+
|
|
114
|
+
p_recall = sub.add_parser("recall", help="Query memory records")
|
|
115
|
+
p_recall.add_argument("--agent")
|
|
116
|
+
p_recall.add_argument("--session-id")
|
|
117
|
+
p_recall.add_argument("--session-type")
|
|
118
|
+
p_recall.add_argument("--type", dest="memory_type")
|
|
119
|
+
p_recall.add_argument("--source")
|
|
120
|
+
p_recall.add_argument("--limit", type=int, default=20)
|
|
121
|
+
|
|
122
|
+
p_linear = sub.add_parser("linear-ingest", help="Ingest Linear issue into memory")
|
|
123
|
+
p_linear.add_argument("issue_id")
|
|
124
|
+
p_linear.add_argument("--session-id", default="linear-sync")
|
|
125
|
+
p_linear.add_argument("--session-type", default="cursor")
|
|
126
|
+
|
|
127
|
+
p_notion = sub.add_parser("notion-ingest", help="Ingest Notion page into memory")
|
|
128
|
+
p_notion.add_argument("page_id")
|
|
129
|
+
p_notion.add_argument("--session-id", default="notion-sync")
|
|
130
|
+
p_notion.add_argument("--session-type", default="cursor")
|
|
131
|
+
|
|
132
|
+
p_export = sub.add_parser("export", help="Export a session's memory as Markdown")
|
|
133
|
+
p_export.add_argument("--session-id", required=True)
|
|
134
|
+
p_export.add_argument("--agent", default=None)
|
|
135
|
+
p_export.add_argument("--output", default=None, help="Write Markdown to this file")
|
|
136
|
+
|
|
137
|
+
p_slack = sub.add_parser("slack-report", help="Post an eval report to Slack")
|
|
138
|
+
p_slack.add_argument("--run-id", default=None)
|
|
139
|
+
p_slack.add_argument("--metrics-json", default=None, help="Path to metrics JSON file")
|
|
140
|
+
|
|
141
|
+
args = parser.parse_args()
|
|
142
|
+
|
|
143
|
+
if args.command == "doctor":
|
|
144
|
+
_doctor()
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
if args.command == "metrics":
|
|
148
|
+
from dreaming_memory.store.postgres import AgentMemoryStore
|
|
149
|
+
|
|
150
|
+
with AgentMemoryStore() as store:
|
|
151
|
+
print(json.dumps(store.metrics(days=args.days), indent=2, default=str))
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if args.command == "serve":
|
|
155
|
+
from dreaming_memory.dashboard import serve
|
|
156
|
+
|
|
157
|
+
serve(host=args.host, port=args.port)
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
if args.command == "triage":
|
|
161
|
+
from dreaming_memory.triage import AutoTriage
|
|
162
|
+
|
|
163
|
+
with AutoTriage() as triage:
|
|
164
|
+
results = triage.run(
|
|
165
|
+
state=args.state, limit=args.limit, apply=args.apply, comment=args.comment
|
|
166
|
+
)
|
|
167
|
+
for r in results:
|
|
168
|
+
print(json.dumps(r.to_content(), default=str))
|
|
169
|
+
print(f"# triaged {len(results)} issue(s); apply={args.apply}")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if args.command == "slack-report":
|
|
173
|
+
from dreaming_memory.integrations.slack import SlackClient
|
|
174
|
+
|
|
175
|
+
metrics: dict = {}
|
|
176
|
+
if args.metrics_json:
|
|
177
|
+
with open(args.metrics_json, encoding="utf-8") as fh:
|
|
178
|
+
metrics = json.load(fh)
|
|
179
|
+
sent = SlackClient().report_eval_result(metrics, run_id=args.run_id)
|
|
180
|
+
if sent:
|
|
181
|
+
print(json.dumps({"slack": "sent", "run_id": args.run_id}))
|
|
182
|
+
else:
|
|
183
|
+
print(json.dumps({"slack": "skipped", "reason": "no SLACK_WEBHOOK_URL configured"}))
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
if args.command == "export":
|
|
187
|
+
# Initialize the store/memory BEFORE use; do not rely on the shared
|
|
188
|
+
# `memory = AgentMemory()` defined later in this function.
|
|
189
|
+
with AgentMemory() as memory:
|
|
190
|
+
records = memory.recall(
|
|
191
|
+
session_id=args.session_id,
|
|
192
|
+
agent_id=args.agent,
|
|
193
|
+
limit=100,
|
|
194
|
+
)
|
|
195
|
+
markdown = render_export_markdown(args.session_id, records)
|
|
196
|
+
if args.output:
|
|
197
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
198
|
+
fh.write(markdown)
|
|
199
|
+
print(json.dumps({
|
|
200
|
+
"export": "written", "path": args.output, "records": len(records)
|
|
201
|
+
}))
|
|
202
|
+
else:
|
|
203
|
+
print(markdown)
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
with AgentMemory() as memory:
|
|
207
|
+
if args.command == "init":
|
|
208
|
+
memory.ensure_schema()
|
|
209
|
+
print("Schema applied.")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
if args.command == "remember":
|
|
213
|
+
ctx = SessionContext(
|
|
214
|
+
session_id=args.session_id,
|
|
215
|
+
session_type=SessionType(args.session_type),
|
|
216
|
+
agent_id=args.agent,
|
|
217
|
+
)
|
|
218
|
+
record = memory.remember(
|
|
219
|
+
ctx,
|
|
220
|
+
MemoryType(args.memory_type),
|
|
221
|
+
json.loads(args.content),
|
|
222
|
+
source=MemorySource(args.source),
|
|
223
|
+
)
|
|
224
|
+
_print_records([record])
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
if args.command == "recall":
|
|
228
|
+
records = memory.recall(
|
|
229
|
+
agent_id=args.agent,
|
|
230
|
+
session_id=args.session_id,
|
|
231
|
+
session_type=SessionType(args.session_type) if args.session_type else None,
|
|
232
|
+
memory_type=MemoryType(args.memory_type) if args.memory_type else None,
|
|
233
|
+
source=MemorySource(args.source) if args.source else None,
|
|
234
|
+
limit=args.limit,
|
|
235
|
+
)
|
|
236
|
+
_print_records(records)
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
if args.command == "linear-ingest":
|
|
240
|
+
ctx = SessionContext(
|
|
241
|
+
session_id=args.session_id,
|
|
242
|
+
session_type=SessionType(args.session_type),
|
|
243
|
+
)
|
|
244
|
+
record = memory.linear.ingest_issue(args.issue_id, ctx)
|
|
245
|
+
_print_records([record])
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
if args.command == "notion-ingest":
|
|
249
|
+
ctx = SessionContext(
|
|
250
|
+
session_id=args.session_id,
|
|
251
|
+
session_type=SessionType(args.session_type),
|
|
252
|
+
)
|
|
253
|
+
record = memory.notion.ingest_page(args.page_id, ctx)
|
|
254
|
+
_print_records([record])
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
parser.print_help()
|
|
258
|
+
sys.exit(2)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
main()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Central config + secrets loader for the agent memory fleet.
|
|
2
|
+
|
|
3
|
+
Resolution order (first match wins):
|
|
4
|
+
1. Process environment (os.environ)
|
|
5
|
+
2. Fleet env file (~/.openclaude/.env — `export KEY="val"` lines, never committed)
|
|
6
|
+
3. Per-host fallback (~/.config/agent-memory/.env)
|
|
7
|
+
|
|
8
|
+
Only read at runtime; secrets are never written to the repo.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_EXPORT_RE = re.compile(r'^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)\s*$')
|
|
19
|
+
|
|
20
|
+
_DEFAULT_SECRET_FILES = (
|
|
21
|
+
Path(os.environ.get("FLEET_ENV_FILE", "")) if os.environ.get("FLEET_ENV_FILE") else None,
|
|
22
|
+
Path.home() / ".openclaude" / ".env",
|
|
23
|
+
Path.home() / ".config" / "agent-memory" / ".env",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_env_file(path: Path | None) -> dict[str, str]:
|
|
28
|
+
if not path or not path.exists():
|
|
29
|
+
return {}
|
|
30
|
+
out: dict[str, str] = {}
|
|
31
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
32
|
+
line = line.strip()
|
|
33
|
+
if not line or line.startswith("#"):
|
|
34
|
+
continue
|
|
35
|
+
m = _EXPORT_RE.match(line)
|
|
36
|
+
if not m:
|
|
37
|
+
continue
|
|
38
|
+
key, val = m.group(1), m.group(2)
|
|
39
|
+
out[key] = val.strip().strip('"').strip("'")
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_fleet_secrets(extra_files: tuple[Path, ...] = ()) -> dict[str, str]:
|
|
44
|
+
"""Load fleet env files into os.environ without overwriting existing vars."""
|
|
45
|
+
merged: dict[str, str] = {}
|
|
46
|
+
for path in (*_DEFAULT_SECRET_FILES, *extra_files):
|
|
47
|
+
merged.update(_parse_env_file(path))
|
|
48
|
+
for key, val in merged.items():
|
|
49
|
+
os.environ.setdefault(key, val)
|
|
50
|
+
return merged
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_secret(key: str, default: str | None = None) -> str | None:
|
|
54
|
+
if key not in os.environ:
|
|
55
|
+
load_fleet_secrets()
|
|
56
|
+
return os.environ.get(key, default)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class FleetConfig:
|
|
61
|
+
"""Resolved secrets/config for all integrations across the fleet."""
|
|
62
|
+
|
|
63
|
+
database_url: str | None = None
|
|
64
|
+
linear_api_key: str | None = None
|
|
65
|
+
linear_team_id: str | None = None
|
|
66
|
+
notion_api_key: str | None = None
|
|
67
|
+
cloudflare_api_token: str | None = None
|
|
68
|
+
cloudflare_account_id: str | None = None
|
|
69
|
+
sentry_dsn: str | None = None
|
|
70
|
+
sentry_environment: str = "development"
|
|
71
|
+
upstash_redis_url: str | None = None
|
|
72
|
+
upstash_redis_token: str | None = None
|
|
73
|
+
lance_db_uri: str | None = None
|
|
74
|
+
r2_bucket: str | None = None
|
|
75
|
+
r2_access_key_id: str | None = None
|
|
76
|
+
r2_secret_access_key: str | None = None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def load(cls) -> FleetConfig:
|
|
80
|
+
load_fleet_secrets()
|
|
81
|
+
return cls(
|
|
82
|
+
database_url=(
|
|
83
|
+
os.environ.get("AGENT_MEMORY_DATABASE_URL")
|
|
84
|
+
or os.environ.get("DATABASE_URL")
|
|
85
|
+
),
|
|
86
|
+
linear_api_key=os.environ.get("LINEAR_API_KEY"),
|
|
87
|
+
linear_team_id=os.environ.get("LINEAR_TEAM_ID"),
|
|
88
|
+
notion_api_key=os.environ.get("NOTION_API_KEY") or os.environ.get("NOTION_TOKEN"),
|
|
89
|
+
cloudflare_api_token=os.environ.get("CLOUDFLARE_API_TOKEN"),
|
|
90
|
+
cloudflare_account_id=os.environ.get("CLOUDFLARE_ACCOUNT_ID"),
|
|
91
|
+
sentry_dsn=os.environ.get("SENTRY_DSN"),
|
|
92
|
+
sentry_environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
|
|
93
|
+
upstash_redis_url=os.environ.get("UPSTASH_REDIS_REST_URL"),
|
|
94
|
+
upstash_redis_token=os.environ.get("UPSTASH_REDIS_REST_TOKEN"),
|
|
95
|
+
lance_db_uri=os.environ.get("LANCE_DB_URI"),
|
|
96
|
+
r2_bucket=os.environ.get("R2_BUCKET")
|
|
97
|
+
or os.environ.get("AGENT_MEMORY_R2_BUCKET")
|
|
98
|
+
or "agent-memory",
|
|
99
|
+
r2_access_key_id=os.environ.get("R2_ACCESS_KEY_ID"),
|
|
100
|
+
r2_secret_access_key=os.environ.get("R2_SECRET_ACCESS_KEY"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def status(self) -> dict[str, bool]:
|
|
104
|
+
"""Boolean presence map — safe to log (no secret values)."""
|
|
105
|
+
return {
|
|
106
|
+
"database_url": bool(self.database_url),
|
|
107
|
+
"linear": bool(self.linear_api_key),
|
|
108
|
+
"notion": bool(self.notion_api_key),
|
|
109
|
+
"cloudflare": bool(self.cloudflare_api_token and self.cloudflare_account_id),
|
|
110
|
+
"sentry": bool(self.sentry_dsn),
|
|
111
|
+
"upstash": bool(self.upstash_redis_url and self.upstash_redis_token),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def redacted(self) -> dict[str, str]:
|
|
115
|
+
"""Masked summary for logging."""
|
|
116
|
+
|
|
117
|
+
def mask(v: str | None) -> str:
|
|
118
|
+
if not v:
|
|
119
|
+
return "(unset)"
|
|
120
|
+
return v[:4] + "…" + v[-2:] if len(v) > 8 else "set"
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
"database_url": re.sub(r":[^:@/]+@", ":***@", self.database_url or "(unset)"),
|
|
124
|
+
"linear_api_key": mask(self.linear_api_key),
|
|
125
|
+
"notion_api_key": mask(self.notion_api_key),
|
|
126
|
+
"cloudflare_api_token": mask(self.cloudflare_api_token),
|
|
127
|
+
"cloudflare_account_id": mask(self.cloudflare_account_id),
|
|
128
|
+
"sentry_dsn": "set" if self.sentry_dsn else "(unset)",
|
|
129
|
+
"lance_db_uri": self.lance_db_uri or "(unset)",
|
|
130
|
+
}
|