ivas 0.1.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.
ivas/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """
2
+ IVAS -- Immortal Virtual Agent Sessions.
3
+
4
+ Persistent memory and session continuity for any AI agent.
5
+ Your agent remembers everything. Forever. Across crashes, restarts, and updates.
6
+
7
+ Quick start:
8
+ from ivas import Brain
9
+
10
+ brain = Brain("my-agent")
11
+ brain.remember("user", "preferences", "User prefers dark mode")
12
+ results = brain.recall("dark mode")
13
+
14
+ Patent: 64/019,813 (Provisional) -- Multi-Instance AI Agent Session Relay System
15
+ with Persistent State Recovery
16
+ """
17
+
18
+ __version__ = "0.1.0"
19
+ __author__ = "Arvind Ramamoorthy"
20
+
21
+ from ivas.brain import Brain
22
+ from ivas.session import Session
23
+ from ivas.manifest import RecallManifest
24
+ from ivas.corrections import Corrections
25
+ from ivas.einherjar import Einherjar
26
+
27
+ __all__ = ["Brain", "Session", "RecallManifest", "Corrections", "Einherjar"]
ivas/brain.py ADDED
@@ -0,0 +1,406 @@
1
+ """
2
+ IVAS Brain -- Persistent memory with full-text search.
3
+
4
+ SQLite + FTS5 for zero-dependency, cross-platform, crash-proof memory.
5
+ No MongoDB. No Redis. No Docker. One file. Works everywhere.
6
+
7
+ Usage:
8
+ brain = Brain("my-agent")
9
+ brain.remember("meeting", "standup", "Decided to ship v2 Friday")
10
+ brain.recall("ship v2") # -> list of memories
11
+ brain.forget(memory_id) # -> remove a memory
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sqlite3
18
+ import time
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+
24
+ @dataclass
25
+ class Memory:
26
+ """A single memory entry."""
27
+ id: int
28
+ category: str
29
+ subject: str
30
+ content: str
31
+ confidence: float
32
+ created_at: float
33
+ source: Optional[str] = None
34
+ metadata: dict = field(default_factory=dict)
35
+
36
+ def age_hours(self) -> float:
37
+ return (time.time() - self.created_at) / 3600
38
+
39
+ def age_days(self) -> float:
40
+ return self.age_hours() / 24
41
+
42
+
43
+ class Brain:
44
+ """Persistent memory store with full-text search.
45
+
46
+ Each agent gets its own SQLite database. Memories are categorized,
47
+ full-text searchable, and survive crashes, restarts, and updates.
48
+
49
+ Args:
50
+ agent_id: Unique identifier for this agent (used as DB filename).
51
+ data_dir: Directory to store the database. Defaults to ~/.ivas/
52
+ auto_vacuum: Run SQLite VACUUM on open (default False).
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ agent_id: str,
58
+ data_dir: Optional[str | Path] = None,
59
+ auto_vacuum: bool = False,
60
+ ):
61
+ self.agent_id = agent_id
62
+ self.data_dir = Path(data_dir) if data_dir else Path.home() / ".ivas"
63
+ self.data_dir.mkdir(parents=True, exist_ok=True)
64
+ self.db_path = self.data_dir / f"{agent_id}.db"
65
+ self._conn = self._connect(auto_vacuum)
66
+ self._setup_tables()
67
+
68
+ def _connect(self, auto_vacuum: bool) -> sqlite3.Connection:
69
+ conn = sqlite3.connect(str(self.db_path), timeout=10)
70
+ conn.execute("PRAGMA journal_mode=WAL")
71
+ conn.execute("PRAGMA synchronous=NORMAL")
72
+ conn.execute("PRAGMA busy_timeout=5000")
73
+ if auto_vacuum:
74
+ conn.execute("VACUUM")
75
+ return conn
76
+
77
+ def _setup_tables(self):
78
+ self._conn.executescript("""
79
+ CREATE TABLE IF NOT EXISTS memories (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ category TEXT NOT NULL,
82
+ subject TEXT NOT NULL,
83
+ content TEXT NOT NULL,
84
+ confidence REAL DEFAULT 0.8,
85
+ source TEXT,
86
+ metadata TEXT DEFAULT '{}',
87
+ created_at REAL NOT NULL,
88
+ updated_at REAL
89
+ );
90
+
91
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
92
+ category, subject, content,
93
+ content='memories',
94
+ content_rowid='id',
95
+ tokenize='porter unicode61'
96
+ );
97
+
98
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
99
+ INSERT INTO memories_fts(rowid, category, subject, content)
100
+ VALUES (new.id, new.category, new.subject, new.content);
101
+ END;
102
+
103
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
104
+ INSERT INTO memories_fts(memories_fts, rowid, category, subject, content)
105
+ VALUES ('delete', old.id, old.category, old.subject, old.content);
106
+ END;
107
+
108
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
109
+ INSERT INTO memories_fts(memories_fts, rowid, category, subject, content)
110
+ VALUES ('delete', old.id, old.category, old.subject, old.content);
111
+ INSERT INTO memories_fts(rowid, category, subject, content)
112
+ VALUES (new.id, new.category, new.subject, new.content);
113
+ END;
114
+
115
+ CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
116
+ CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
117
+ """)
118
+ self._conn.commit()
119
+
120
+ def remember(
121
+ self,
122
+ category: str,
123
+ subject: str,
124
+ content: str,
125
+ confidence: float = 0.8,
126
+ source: Optional[str] = None,
127
+ metadata: Optional[dict] = None,
128
+ ) -> int:
129
+ """Store a memory. Returns the memory ID.
130
+
131
+ Args:
132
+ category: High-level category (e.g., "user", "decision", "error").
133
+ subject: Specific topic (e.g., "dark_mode_preference").
134
+ content: The actual memory content.
135
+ confidence: How confident you are (0.0 to 1.0).
136
+ source: Where this memory came from (optional).
137
+ metadata: Additional key-value data (optional).
138
+
139
+ Returns:
140
+ The integer ID of the stored memory.
141
+ """
142
+ now = time.time()
143
+ cursor = self._conn.execute(
144
+ """INSERT INTO memories (category, subject, content, confidence, source, metadata, created_at)
145
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
146
+ (category, subject, content, confidence, source,
147
+ json.dumps(metadata or {}), now),
148
+ )
149
+ self._conn.commit()
150
+ return cursor.lastrowid
151
+
152
+ # Stopwords to skip when building auto OR-join queries
153
+ _STOPWORDS = frozenset({
154
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
155
+ "have", "has", "had", "do", "does", "did", "will", "would", "shall",
156
+ "should", "may", "might", "can", "could", "to", "of", "in", "for",
157
+ "on", "with", "at", "by", "from", "as", "into", "about", "that",
158
+ "this", "it", "its", "i", "you", "he", "she", "we", "they", "my",
159
+ "your", "his", "her", "our", "their", "what", "which", "who", "whom",
160
+ "when", "where", "how", "why", "not", "no", "and", "or", "but", "if",
161
+ "so", "than", "too", "very", "just", "also", "then", "there", "here",
162
+ })
163
+
164
+ @staticmethod
165
+ def _is_explicit_fts5(query: str) -> bool:
166
+ """Return True if the query already contains explicit FTS5 syntax."""
167
+ import re
168
+ # Explicit FTS5 markers: OR / AND / NOT (uppercase), quoted phrases
169
+ return bool(re.search(r'\bOR\b|\bAND\b|\bNOT\b|"', query))
170
+
171
+ def _sanitize_query(self, query: str) -> str:
172
+ """Convert a plain natural-language query into an FTS5 OR-joined query.
173
+
174
+ If the query already uses explicit FTS5 syntax (OR, AND, NOT, quotes)
175
+ it is returned unchanged. Otherwise punctuation is stripped, stopwords
176
+ are removed, and the remaining content words are joined with OR so that
177
+ multi-word queries like "notification preference" find memories that
178
+ mention either word.
179
+ """
180
+ import re
181
+ if self._is_explicit_fts5(query):
182
+ return query
183
+ # Strip punctuation, keep word characters and spaces
184
+ clean = re.sub(r'[^\w\s]', ' ', query)
185
+ words = [
186
+ w for w in clean.split()
187
+ if len(w) > 2 and w.lower() not in self._STOPWORDS
188
+ ]
189
+ if not words:
190
+ # Fallback: use cleaned query as-is to avoid empty MATCH
191
+ return re.sub(r'[^\w\s]', ' ', query).strip() or query
192
+ return " OR ".join(words)
193
+
194
+ def recall(
195
+ self,
196
+ query: str,
197
+ limit: int = 10,
198
+ category: Optional[str] = None,
199
+ min_confidence: float = 0.0,
200
+ ) -> list[Memory]:
201
+ """Search memories using full-text search.
202
+
203
+ Args:
204
+ query: Search query (supports FTS5 syntax: AND, OR, NOT, quotes).
205
+ Plain natural-language queries are automatically OR-joined
206
+ so that partial matches are returned.
207
+ limit: Maximum results to return.
208
+ category: Filter to this category only (optional).
209
+ min_confidence: Minimum confidence threshold.
210
+
211
+ Returns:
212
+ List of Memory objects, ranked by relevance then recency.
213
+ """
214
+ # Sanitize plain queries into FTS5 OR-join; pass explicit syntax through
215
+ fts_query = self._sanitize_query(query)
216
+
217
+ # Build FTS query with optional category filter
218
+ params: list = []
219
+ sql = """
220
+ SELECT m.id, m.category, m.subject, m.content, m.confidence,
221
+ m.created_at, m.source, m.metadata,
222
+ rank
223
+ FROM memories_fts fts
224
+ JOIN memories m ON m.id = fts.rowid
225
+ WHERE memories_fts MATCH ?
226
+ """
227
+ params.append(fts_query)
228
+
229
+ if category:
230
+ sql += " AND m.category = ?"
231
+ params.append(category)
232
+
233
+ if min_confidence > 0:
234
+ sql += " AND m.confidence >= ?"
235
+ params.append(min_confidence)
236
+
237
+ # Primary sort: FTS5 rank (more negative = more relevant).
238
+ # Tiebreaker: newest memory first, so corrections/updates surface above
239
+ # older entries when relevance scores are equal.
240
+ sql += " ORDER BY rank, m.created_at DESC LIMIT ?"
241
+ params.append(limit)
242
+
243
+ rows = self._conn.execute(sql, params).fetchall()
244
+ return [self._row_to_memory(r) for r in rows]
245
+
246
+ def get(self, memory_id: int) -> Optional[Memory]:
247
+ """Get a specific memory by ID."""
248
+ row = self._conn.execute(
249
+ """SELECT id, category, subject, content, confidence,
250
+ created_at, source, metadata, 0 as rank
251
+ FROM memories WHERE id = ?""",
252
+ (memory_id,),
253
+ ).fetchone()
254
+ return self._row_to_memory(row) if row else None
255
+
256
+ def update(
257
+ self,
258
+ memory_id: int,
259
+ content: Optional[str] = None,
260
+ confidence: Optional[float] = None,
261
+ metadata: Optional[dict] = None,
262
+ ) -> bool:
263
+ """Update an existing memory. Returns True if found and updated."""
264
+ existing = self.get(memory_id)
265
+ if not existing:
266
+ return False
267
+
268
+ updates = []
269
+ params = []
270
+ if content is not None:
271
+ updates.append("content = ?")
272
+ params.append(content)
273
+ if confidence is not None:
274
+ updates.append("confidence = ?")
275
+ params.append(confidence)
276
+ if metadata is not None:
277
+ updates.append("metadata = ?")
278
+ params.append(json.dumps(metadata))
279
+
280
+ if not updates:
281
+ return True
282
+
283
+ updates.append("updated_at = ?")
284
+ params.append(time.time())
285
+ params.append(memory_id)
286
+
287
+ self._conn.execute(
288
+ f"UPDATE memories SET {', '.join(updates)} WHERE id = ?",
289
+ params,
290
+ )
291
+ self._conn.commit()
292
+ return True
293
+
294
+ def forget(self, memory_id: int) -> bool:
295
+ """Delete a memory. Returns True if it existed."""
296
+ cursor = self._conn.execute(
297
+ "DELETE FROM memories WHERE id = ?", (memory_id,)
298
+ )
299
+ self._conn.commit()
300
+ return cursor.rowcount > 0
301
+
302
+ def recent(self, limit: int = 20, category: Optional[str] = None) -> list[Memory]:
303
+ """Get the most recent memories."""
304
+ if category:
305
+ rows = self._conn.execute(
306
+ """SELECT id, category, subject, content, confidence,
307
+ created_at, source, metadata, 0 as rank
308
+ FROM memories WHERE category = ?
309
+ ORDER BY created_at DESC LIMIT ?""",
310
+ (category, limit),
311
+ ).fetchall()
312
+ else:
313
+ rows = self._conn.execute(
314
+ """SELECT id, category, subject, content, confidence,
315
+ created_at, source, metadata, 0 as rank
316
+ FROM memories ORDER BY created_at DESC LIMIT ?""",
317
+ (limit,),
318
+ ).fetchall()
319
+ return [self._row_to_memory(r) for r in rows]
320
+
321
+ def stats(self) -> dict:
322
+ """Get memory statistics."""
323
+ total = self._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
324
+ categories = self._conn.execute(
325
+ "SELECT category, COUNT(*) FROM memories GROUP BY category ORDER BY COUNT(*) DESC"
326
+ ).fetchall()
327
+ oldest = self._conn.execute(
328
+ "SELECT MIN(created_at) FROM memories"
329
+ ).fetchone()[0]
330
+ newest = self._conn.execute(
331
+ "SELECT MAX(created_at) FROM memories"
332
+ ).fetchone()[0]
333
+
334
+ return {
335
+ "agent_id": self.agent_id,
336
+ "total_memories": total,
337
+ "categories": {c: n for c, n in categories},
338
+ "oldest_memory": oldest,
339
+ "newest_memory": newest,
340
+ "db_path": str(self.db_path),
341
+ "db_size_mb": round(self.db_path.stat().st_size / 1024 / 1024, 2),
342
+ }
343
+
344
+ def export(self, path: Optional[str | Path] = None) -> str:
345
+ """Export all memories to JSON. Returns the JSON string."""
346
+ rows = self._conn.execute(
347
+ """SELECT id, category, subject, content, confidence,
348
+ created_at, source, metadata
349
+ FROM memories ORDER BY created_at"""
350
+ ).fetchall()
351
+ memories = []
352
+ for r in rows:
353
+ memories.append({
354
+ "id": r[0], "category": r[1], "subject": r[2],
355
+ "content": r[3], "confidence": r[4], "created_at": r[5],
356
+ "source": r[6], "metadata": json.loads(r[7] or "{}"),
357
+ })
358
+ data = json.dumps({"agent_id": self.agent_id, "memories": memories}, indent=2)
359
+ if path:
360
+ Path(path).write_text(data)
361
+ return data
362
+
363
+ def import_from(self, path: str | Path) -> int:
364
+ """Import memories from a JSON export. Returns count imported."""
365
+ data = json.loads(Path(path).read_text())
366
+ count = 0
367
+ for m in data.get("memories", []):
368
+ self.remember(
369
+ category=m["category"],
370
+ subject=m["subject"],
371
+ content=m["content"],
372
+ confidence=m.get("confidence", 0.8),
373
+ source=m.get("source"),
374
+ metadata=m.get("metadata"),
375
+ )
376
+ count += 1
377
+ return count
378
+
379
+ def close(self):
380
+ """Close the database connection."""
381
+ self._conn.close()
382
+
383
+ def __enter__(self):
384
+ return self
385
+
386
+ def __exit__(self, *args):
387
+ self.close()
388
+
389
+ def __len__(self) -> int:
390
+ return self._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
391
+
392
+ def __repr__(self) -> str:
393
+ return f"Brain(agent_id={self.agent_id!r}, memories={len(self)}, db={self.db_path})"
394
+
395
+ @staticmethod
396
+ def _row_to_memory(row) -> Memory:
397
+ return Memory(
398
+ id=row[0],
399
+ category=row[1],
400
+ subject=row[2],
401
+ content=row[3],
402
+ confidence=row[4],
403
+ created_at=row[5],
404
+ source=row[6],
405
+ metadata=json.loads(row[7] or "{}"),
406
+ )
ivas/cli.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ IVAS CLI -- Command-line interface for IVAS.
3
+
4
+ Usage:
5
+ ivas remember <category> <subject> <content>
6
+ ivas recall <query>
7
+ ivas stats
8
+ ivas export [--output <path>]
9
+ ivas import <path>
10
+ ivas session restore
11
+ ivas session save <task> [<thought>]
12
+ ivas version
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ import time
21
+
22
+ from ivas.brain import Brain
23
+ from ivas.session import Session
24
+
25
+
26
+ def _get_brain(args) -> Brain:
27
+ agent_id = getattr(args, "agent", None) or "default"
28
+ data_dir = getattr(args, "data_dir", None)
29
+ return Brain(agent_id, data_dir=data_dir)
30
+
31
+
32
+ def cmd_remember(args):
33
+ brain = _get_brain(args)
34
+ mem_id = brain.remember(args.category, args.subject, args.content)
35
+ print(f"Remembered #{mem_id}: [{args.category}] {args.subject}")
36
+ brain.close()
37
+
38
+
39
+ def cmd_recall(args):
40
+ brain = _get_brain(args)
41
+ results = brain.recall(args.query, limit=args.limit)
42
+ if not results:
43
+ print("No memories found.")
44
+ else:
45
+ for m in results:
46
+ age = m.age_hours()
47
+ age_str = f"{age:.1f}h" if age < 24 else f"{age/24:.1f}d"
48
+ print(f" [{m.id}] {m.category}/{m.subject} ({age_str} ago)")
49
+ print(f" {m.content[:120]}")
50
+ print()
51
+ brain.close()
52
+
53
+
54
+ def cmd_stats(args):
55
+ brain = _get_brain(args)
56
+ stats = brain.stats()
57
+ print(f"Agent: {stats['agent_id']}")
58
+ print(f"Total memories: {stats['total_memories']}")
59
+ print(f"Database: {stats['db_path']} ({stats['db_size_mb']} MB)")
60
+ if stats['categories']:
61
+ print("Categories:")
62
+ for cat, count in stats['categories'].items():
63
+ print(f" {cat}: {count}")
64
+ brain.close()
65
+
66
+
67
+ def cmd_export(args):
68
+ brain = _get_brain(args)
69
+ output = args.output or f"{brain.agent_id}_export.json"
70
+ data = brain.export(output)
71
+ count = len(json.loads(data).get("memories", []))
72
+ print(f"Exported {count} memories to {output}")
73
+ brain.close()
74
+
75
+
76
+ def cmd_import(args):
77
+ brain = _get_brain(args)
78
+ count = brain.import_from(args.path)
79
+ print(f"Imported {count} memories from {args.path}")
80
+ brain.close()
81
+
82
+
83
+ def cmd_session_save(args):
84
+ brain = _get_brain(args)
85
+ session = Session(brain.agent_id, brain=brain)
86
+ state = session.save_state(
87
+ active_task=args.task,
88
+ current_thought=args.thought,
89
+ )
90
+ print(f"State saved: {state.active_task}")
91
+ brain.close()
92
+
93
+
94
+ def cmd_session_restore(args):
95
+ brain = _get_brain(args)
96
+ session = Session(brain.agent_id, brain=brain)
97
+ state = session.restore()
98
+ if state:
99
+ print(f"Restored from: {state.restored_from}")
100
+ print(f"Active task: {state.active_task}")
101
+ print(f"Current thought: {state.current_thought}")
102
+ print(f"Decisions: {json.dumps(state.decisions, indent=2)}")
103
+ print(f"Pending: {state.pending_actions}")
104
+ age = state.age_seconds()
105
+ print(f"Age: {age:.0f}s ({age/3600:.1f}h)")
106
+ else:
107
+ print("No prior state found.")
108
+ brain.close()
109
+
110
+
111
+ def cmd_version(_args):
112
+ from ivas import __version__
113
+ print(f"ivas {__version__}")
114
+
115
+
116
+ def main(argv: list[str] | None = None):
117
+ parser = argparse.ArgumentParser(
118
+ prog="ivas",
119
+ description="IVAS -- Immortal Virtual Agent Sessions",
120
+ )
121
+ parser.add_argument("--agent", "-a", default="default", help="Agent ID")
122
+ parser.add_argument("--data-dir", "-d", help="Data directory (default: ~/.ivas/)")
123
+
124
+ sub = parser.add_subparsers(dest="command")
125
+
126
+ # remember
127
+ p_rem = sub.add_parser("remember", help="Store a memory")
128
+ p_rem.add_argument("category")
129
+ p_rem.add_argument("subject")
130
+ p_rem.add_argument("content")
131
+
132
+ # recall
133
+ p_rec = sub.add_parser("recall", help="Search memories")
134
+ p_rec.add_argument("query")
135
+ p_rec.add_argument("--limit", "-n", type=int, default=10)
136
+
137
+ # stats
138
+ sub.add_parser("stats", help="Show memory statistics")
139
+
140
+ # export
141
+ p_exp = sub.add_parser("export", help="Export memories to JSON")
142
+ p_exp.add_argument("--output", "-o")
143
+
144
+ # import
145
+ p_imp = sub.add_parser("import", help="Import memories from JSON")
146
+ p_imp.add_argument("path")
147
+
148
+ # session save
149
+ p_ss = sub.add_parser("save", help="Save session state")
150
+ p_ss.add_argument("task", help="Current task description")
151
+ p_ss.add_argument("thought", nargs="?", help="Current thought")
152
+
153
+ # session restore
154
+ sub.add_parser("restore", help="Restore last session state")
155
+
156
+ # version
157
+ sub.add_parser("version", help="Show version")
158
+
159
+ args = parser.parse_args(argv)
160
+
161
+ commands = {
162
+ "remember": cmd_remember,
163
+ "recall": cmd_recall,
164
+ "stats": cmd_stats,
165
+ "export": cmd_export,
166
+ "import": cmd_import,
167
+ "save": cmd_session_save,
168
+ "restore": cmd_session_restore,
169
+ "version": cmd_version,
170
+ }
171
+
172
+ if args.command in commands:
173
+ commands[args.command](args)
174
+ else:
175
+ parser.print_help()
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()