loki-mode 5.58.0 → 5.58.2
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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +2 -1
- package/autonomy/completion-council.sh +9 -3
- package/autonomy/loki +19 -10
- package/autonomy/prd-checklist.sh +2 -1
- package/autonomy/run.sh +49 -7
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +3 -2
- package/dashboard/migration_engine.py +46 -17
- package/dashboard/server.py +1 -1
- package/dashboard/static/index.html +38 -38
- package/docs/INSTALLATION.md +1 -1
- package/events/bus.ts +45 -2
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +17 -12
- package/mcp/tools.py +9 -2
- package/memory/consolidation.py +9 -6
- package/memory/engine.py +7 -1
- package/memory/namespace.py +38 -8
- package/memory/retrieval.py +16 -12
- package/memory/schemas.py +59 -23
- package/memory/storage.py +15 -6
- package/memory/vector_index.py +7 -1
- package/package.json +1 -1
- package/providers/gemini.sh +15 -7
package/docs/INSTALLATION.md
CHANGED
package/events/bus.ts
CHANGED
|
@@ -69,6 +69,49 @@ function getTimestamp(): string {
|
|
|
69
69
|
return new Date().toISOString();
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Write a file with an exclusive lockfile to prevent concurrent corruption.
|
|
74
|
+
* Creates a .lock file, writes data, then removes the lock.
|
|
75
|
+
*/
|
|
76
|
+
function writeFileWithLock(filepath: string, data: string): void {
|
|
77
|
+
const lockfile = filepath + ".lock";
|
|
78
|
+
let fd: number | null = null;
|
|
79
|
+
try {
|
|
80
|
+
// Acquire lock by creating lockfile exclusively (fails if already exists)
|
|
81
|
+
fd = fs.openSync(lockfile, "wx");
|
|
82
|
+
fs.closeSync(fd);
|
|
83
|
+
fd = null;
|
|
84
|
+
// Write the actual data
|
|
85
|
+
fs.writeFileSync(filepath, data);
|
|
86
|
+
} catch (e: unknown) {
|
|
87
|
+
// If lock acquisition failed (file exists), retry once after brief delay
|
|
88
|
+
if (e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "EEXIST") {
|
|
89
|
+
// Another process holds the lock; wait briefly and retry
|
|
90
|
+
const start = Date.now();
|
|
91
|
+
while (fs.existsSync(lockfile) && Date.now() - start < 1000) {
|
|
92
|
+
// Busy-wait up to 1 second
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
fd = fs.openSync(lockfile, "wx");
|
|
96
|
+
fs.closeSync(fd);
|
|
97
|
+
fd = null;
|
|
98
|
+
fs.writeFileSync(filepath, data);
|
|
99
|
+
} catch {
|
|
100
|
+
// Fall back to unlocked write
|
|
101
|
+
fs.writeFileSync(filepath, data);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
try {
|
|
108
|
+
fs.unlinkSync(lockfile);
|
|
109
|
+
} catch {
|
|
110
|
+
// Ignore cleanup errors
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
/**
|
|
73
116
|
* File-based Event Bus
|
|
74
117
|
*/
|
|
@@ -132,7 +175,7 @@ export class EventBus {
|
|
|
132
175
|
}
|
|
133
176
|
|
|
134
177
|
try {
|
|
135
|
-
|
|
178
|
+
writeFileWithLock(
|
|
136
179
|
this.processedFile,
|
|
137
180
|
JSON.stringify({ ids: Array.from(this.processedIds) })
|
|
138
181
|
);
|
|
@@ -158,7 +201,7 @@ export class EventBus {
|
|
|
158
201
|
const filepath = path.join(this.pendingDir, filename);
|
|
159
202
|
|
|
160
203
|
try {
|
|
161
|
-
|
|
204
|
+
writeFileWithLock(filepath, JSON.stringify(fullEvent, null, 2));
|
|
162
205
|
} catch (e) {
|
|
163
206
|
throw new Error(`Failed to emit event: ${e}`);
|
|
164
207
|
}
|
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -248,6 +248,7 @@ logger = logging.getLogger('loki-mcp')
|
|
|
248
248
|
|
|
249
249
|
# Track tool call start times for duration calculation (per-tool stack)
|
|
250
250
|
_tool_call_start_times: Dict[str, List[float]] = {}
|
|
251
|
+
_tool_call_times_lock = threading.Lock()
|
|
251
252
|
|
|
252
253
|
|
|
253
254
|
def _emit_tool_event_async(tool_name: str, action: str, **kwargs) -> None:
|
|
@@ -261,15 +262,17 @@ def _emit_tool_event_async(tool_name: str, action: str, **kwargs) -> None:
|
|
|
261
262
|
"""
|
|
262
263
|
import time
|
|
263
264
|
|
|
264
|
-
# Track timing for learning signals using a per-tool-name stack
|
|
265
|
+
# Track timing for learning signals using a per-tool-name stack (thread-safe)
|
|
265
266
|
if action == 'start':
|
|
266
|
-
|
|
267
|
+
with _tool_call_times_lock:
|
|
268
|
+
_tool_call_start_times.setdefault(tool_name, []).append(time.time())
|
|
267
269
|
elif action == 'complete':
|
|
268
270
|
# Pop the most recent start time for this tool
|
|
269
271
|
start_time = None
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
272
|
+
with _tool_call_times_lock:
|
|
273
|
+
times = _tool_call_start_times.get(tool_name)
|
|
274
|
+
if times:
|
|
275
|
+
start_time = times.pop()
|
|
273
276
|
if start_time:
|
|
274
277
|
execution_time_ms = int((time.time() - start_time) * 1000)
|
|
275
278
|
_emit_learning_signal_async(
|
|
@@ -674,8 +677,10 @@ async def loki_task_queue_add(
|
|
|
674
677
|
else:
|
|
675
678
|
queue = {"tasks": [], "version": "1.0"}
|
|
676
679
|
|
|
677
|
-
# Create new task
|
|
678
|
-
|
|
680
|
+
# Create new task with monotonic counter to avoid ID collisions after deletions
|
|
681
|
+
next_id = queue.get("_next_id", len(queue['tasks']) + 1)
|
|
682
|
+
task_id = f"task-{next_id:04d}"
|
|
683
|
+
queue["_next_id"] = next_id + 1
|
|
679
684
|
task = {
|
|
680
685
|
"id": task_id,
|
|
681
686
|
"title": title,
|
|
@@ -1064,7 +1069,7 @@ async def loki_project_status() -> str:
|
|
|
1064
1069
|
status["tasks"] = {
|
|
1065
1070
|
"total": len(tasks),
|
|
1066
1071
|
"pending": sum(1 for t in tasks if t.get("status") == "pending"),
|
|
1067
|
-
"in_progress": sum(1 for t in tasks if t.get("status") == "
|
|
1072
|
+
"in_progress": sum(1 for t in tasks if t.get("status") == "in_progress"),
|
|
1068
1073
|
"completed": sum(1 for t in tasks if t.get("status") == "completed"),
|
|
1069
1074
|
}
|
|
1070
1075
|
|
|
@@ -1098,8 +1103,8 @@ async def loki_agent_metrics() -> str:
|
|
|
1098
1103
|
if os.path.isdir(metrics_dir):
|
|
1099
1104
|
for fname in os.listdir(metrics_dir):
|
|
1100
1105
|
if fname.endswith('.json'):
|
|
1101
|
-
fpath =
|
|
1102
|
-
with
|
|
1106
|
+
fpath = safe_path_join('.loki', 'metrics', 'efficiency', fname)
|
|
1107
|
+
with safe_open(fpath, 'r') as f:
|
|
1103
1108
|
metrics["agents"].append(json.load(f))
|
|
1104
1109
|
|
|
1105
1110
|
# Read token economics
|
|
@@ -1139,8 +1144,8 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
|
|
|
1139
1144
|
checkpoints = []
|
|
1140
1145
|
for fname in sorted(os.listdir(cp_dir)):
|
|
1141
1146
|
if fname.endswith('.json'):
|
|
1142
|
-
fpath =
|
|
1143
|
-
with
|
|
1147
|
+
fpath = safe_path_join('.loki', 'state', 'checkpoints', fname)
|
|
1148
|
+
with safe_open(fpath, 'r') as f:
|
|
1144
1149
|
cp = json.load(f)
|
|
1145
1150
|
cp["id"] = fname.replace('.json', '')
|
|
1146
1151
|
checkpoints.append(cp)
|
package/mcp/tools.py
CHANGED
|
@@ -100,9 +100,11 @@ def create_task(
|
|
|
100
100
|
) -> Dict[str, Any]:
|
|
101
101
|
"""Create a new task dictionary."""
|
|
102
102
|
queue = load_task_queue()
|
|
103
|
-
|
|
103
|
+
next_id = queue.get("_next_id", len(queue['tasks']) + 1)
|
|
104
|
+
task_id = f"task-{next_id:04d}"
|
|
105
|
+
queue["_next_id"] = next_id + 1
|
|
104
106
|
|
|
105
|
-
|
|
107
|
+
task = {
|
|
106
108
|
"id": task_id,
|
|
107
109
|
"title": title,
|
|
108
110
|
"description": description,
|
|
@@ -112,6 +114,11 @@ def create_task(
|
|
|
112
114
|
"created_at": datetime.now(timezone.utc).isoformat()
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
# Persist _next_id so it survives across calls
|
|
118
|
+
save_task_queue(queue)
|
|
119
|
+
|
|
120
|
+
return task
|
|
121
|
+
|
|
115
122
|
|
|
116
123
|
def filter_tasks_by_status(tasks: List[Dict], status: str) -> List[Dict]:
|
|
117
124
|
"""Filter tasks by their status."""
|
package/memory/consolidation.py
CHANGED
|
@@ -540,11 +540,14 @@ class ConsolidationPipeline:
|
|
|
540
540
|
if not failed_episodes:
|
|
541
541
|
return []
|
|
542
542
|
|
|
543
|
-
# Group failures by error type
|
|
543
|
+
# Group failures by error type (use set to avoid duplicate episodes)
|
|
544
544
|
error_groups: Dict[str, List[EpisodeTrace]] = defaultdict(list)
|
|
545
|
+
seen_episodes: Dict[str, set] = defaultdict(set)
|
|
545
546
|
for episode in failed_episodes:
|
|
546
|
-
for
|
|
547
|
-
|
|
547
|
+
for err_entry in episode.errors_encountered:
|
|
548
|
+
if episode.id not in seen_episodes[err_entry.error_type]:
|
|
549
|
+
error_groups[err_entry.error_type].append(episode)
|
|
550
|
+
seen_episodes[err_entry.error_type].add(episode.id)
|
|
548
551
|
|
|
549
552
|
anti_patterns = []
|
|
550
553
|
|
|
@@ -564,9 +567,9 @@ class ConsolidationPipeline:
|
|
|
564
567
|
)
|
|
565
568
|
|
|
566
569
|
# Collect resolutions
|
|
567
|
-
for
|
|
568
|
-
if
|
|
569
|
-
resolutions.append(
|
|
570
|
+
for err_entry in episode.errors_encountered:
|
|
571
|
+
if err_entry.error_type == error_type and err_entry.resolution:
|
|
572
|
+
resolutions.append(err_entry.resolution)
|
|
570
573
|
|
|
571
574
|
# Create anti-pattern
|
|
572
575
|
incorrect_approach = self._summarize_actions(pre_error_actions)
|
package/memory/engine.py
CHANGED
|
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
|
|
|
14
14
|
from .schemas import (
|
|
15
15
|
ActionEntry,
|
|
16
16
|
ErrorEntry,
|
|
17
|
+
ErrorFix,
|
|
17
18
|
EpisodeTrace,
|
|
18
19
|
Link,
|
|
19
20
|
ProceduralSkill,
|
|
@@ -939,13 +940,18 @@ class MemoryEngine:
|
|
|
939
940
|
|
|
940
941
|
def _dict_to_skill(self, data: Dict[str, Any]) -> ProceduralSkill:
|
|
941
942
|
"""Convert dictionary to ProceduralSkill."""
|
|
943
|
+
raw_errors = data.get("common_errors", [])
|
|
944
|
+
common_errors = [
|
|
945
|
+
ErrorFix.from_dict(e) if isinstance(e, dict) else e
|
|
946
|
+
for e in raw_errors
|
|
947
|
+
]
|
|
942
948
|
return ProceduralSkill(
|
|
943
949
|
id=data.get("id", ""),
|
|
944
950
|
name=data.get("name", ""),
|
|
945
951
|
description=data.get("description", ""),
|
|
946
952
|
prerequisites=data.get("prerequisites", []),
|
|
947
953
|
steps=data.get("steps", []),
|
|
948
|
-
common_errors=
|
|
954
|
+
common_errors=common_errors,
|
|
949
955
|
exit_criteria=data.get("exit_criteria", []),
|
|
950
956
|
)
|
|
951
957
|
|
package/memory/namespace.py
CHANGED
|
@@ -15,14 +15,16 @@ Based on competitor analysis (claude-mem project isolation patterns).
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import fcntl
|
|
18
19
|
import json
|
|
19
20
|
import os
|
|
20
21
|
import re
|
|
21
22
|
import subprocess
|
|
23
|
+
from contextlib import contextmanager
|
|
22
24
|
from dataclasses import dataclass, field
|
|
23
25
|
from datetime import datetime, timezone
|
|
24
26
|
from pathlib import Path
|
|
25
|
-
from typing import Any, Dict, List, Optional, Set
|
|
27
|
+
from typing import Any, Dict, Generator, List, Optional, Set
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
# Default namespace when none can be detected
|
|
@@ -65,7 +67,12 @@ class NamespaceInfo:
|
|
|
65
67
|
"metadata": self.metadata,
|
|
66
68
|
}
|
|
67
69
|
if self.created_at:
|
|
68
|
-
|
|
70
|
+
iso = self.created_at.isoformat()
|
|
71
|
+
if iso.endswith("+00:00"):
|
|
72
|
+
iso = iso[:-6] + "Z"
|
|
73
|
+
elif not iso.endswith("Z"):
|
|
74
|
+
iso = iso + "Z"
|
|
75
|
+
result["created_at"] = iso
|
|
69
76
|
return result
|
|
70
77
|
|
|
71
78
|
@classmethod
|
|
@@ -127,21 +134,44 @@ class NamespaceManager:
|
|
|
127
134
|
with open(registry_path, "w") as f:
|
|
128
135
|
json.dump(initial_registry, f, indent=2)
|
|
129
136
|
|
|
137
|
+
@contextmanager
|
|
138
|
+
def _file_lock(self, path: Path, exclusive: bool = True) -> Generator[None, None, None]:
|
|
139
|
+
"""Acquire a file lock for safe concurrent access."""
|
|
140
|
+
lock_path = Path(str(path) + ".lock")
|
|
141
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
lock_file = None
|
|
144
|
+
try:
|
|
145
|
+
lock_file = open(lock_path, "w")
|
|
146
|
+
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
147
|
+
fcntl.flock(lock_file.fileno(), lock_type)
|
|
148
|
+
yield
|
|
149
|
+
finally:
|
|
150
|
+
if lock_file is not None:
|
|
151
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
152
|
+
lock_file.close()
|
|
153
|
+
try:
|
|
154
|
+
os.remove(lock_path)
|
|
155
|
+
except OSError:
|
|
156
|
+
pass
|
|
157
|
+
|
|
130
158
|
def _load_registry(self) -> Dict[str, Any]:
|
|
131
|
-
"""Load the namespace registry."""
|
|
159
|
+
"""Load the namespace registry with shared file lock."""
|
|
132
160
|
registry_path = self.base_path / self.REGISTRY_FILE
|
|
133
161
|
if not registry_path.exists():
|
|
134
162
|
return {"version": "1.0.0", "namespaces": {}}
|
|
135
163
|
|
|
136
|
-
with
|
|
137
|
-
|
|
164
|
+
with self._file_lock(registry_path, exclusive=False):
|
|
165
|
+
with open(registry_path, "r") as f:
|
|
166
|
+
return json.load(f)
|
|
138
167
|
|
|
139
168
|
def _save_registry(self, registry: Dict[str, Any]) -> None:
|
|
140
|
-
"""Save the namespace registry."""
|
|
169
|
+
"""Save the namespace registry with exclusive file lock."""
|
|
141
170
|
registry_path = self.base_path / self.REGISTRY_FILE
|
|
142
171
|
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
143
|
-
with
|
|
144
|
-
|
|
172
|
+
with self._file_lock(registry_path, exclusive=True):
|
|
173
|
+
with open(registry_path, "w") as f:
|
|
174
|
+
json.dump(registry, f, indent=2)
|
|
145
175
|
|
|
146
176
|
# -------------------------------------------------------------------------
|
|
147
177
|
# Auto-Detection
|
package/memory/retrieval.py
CHANGED
|
@@ -1379,9 +1379,10 @@ class MemoryRetrieval:
|
|
|
1379
1379
|
score += sum(0.5 for kw in keywords if kw in phase)
|
|
1380
1380
|
|
|
1381
1381
|
if score > 0:
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1382
|
+
data_copy = dict(data)
|
|
1383
|
+
data_copy["_score"] = score
|
|
1384
|
+
data_copy["_source"] = "episodic"
|
|
1385
|
+
results.append(data_copy)
|
|
1385
1386
|
|
|
1386
1387
|
return results
|
|
1387
1388
|
|
|
@@ -1407,9 +1408,10 @@ class MemoryRetrieval:
|
|
|
1407
1408
|
score *= confidence
|
|
1408
1409
|
|
|
1409
1410
|
if score > 0:
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1411
|
+
pattern_copy = dict(pattern)
|
|
1412
|
+
pattern_copy["_score"] = score
|
|
1413
|
+
pattern_copy["_source"] = "semantic"
|
|
1414
|
+
results.append(pattern_copy)
|
|
1413
1415
|
|
|
1414
1416
|
return results
|
|
1415
1417
|
|
|
@@ -1435,9 +1437,10 @@ class MemoryRetrieval:
|
|
|
1435
1437
|
score += sum(0.5 for kw in keywords if kw in steps_text)
|
|
1436
1438
|
|
|
1437
1439
|
if score > 0:
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1440
|
+
data_copy = dict(data)
|
|
1441
|
+
data_copy["_score"] = score
|
|
1442
|
+
data_copy["_source"] = "skills"
|
|
1443
|
+
results.append(data_copy)
|
|
1441
1444
|
|
|
1442
1445
|
return results
|
|
1443
1446
|
|
|
@@ -1459,9 +1462,10 @@ class MemoryRetrieval:
|
|
|
1459
1462
|
score += sum(1 for kw in keywords if kw in prevention)
|
|
1460
1463
|
|
|
1461
1464
|
if score > 0:
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
+
anti_copy = dict(anti)
|
|
1466
|
+
anti_copy["_score"] = score
|
|
1467
|
+
anti_copy["_source"] = "anti_patterns"
|
|
1468
|
+
results.append(anti_copy)
|
|
1465
1469
|
|
|
1466
1470
|
return results
|
|
1467
1471
|
|
package/memory/schemas.py
CHANGED
|
@@ -17,6 +17,35 @@ from datetime import datetime, timezone
|
|
|
17
17
|
from typing import Optional, List, Dict, Any
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _to_utc_isoformat(dt: datetime) -> str:
|
|
21
|
+
"""Convert datetime to UTC ISO 8601 string with Z suffix.
|
|
22
|
+
|
|
23
|
+
Handles both timezone-aware and timezone-naive datetimes,
|
|
24
|
+
and avoids double-suffixing if already has timezone info.
|
|
25
|
+
"""
|
|
26
|
+
iso = dt.isoformat()
|
|
27
|
+
# If already has timezone offset like +00:00, replace with Z
|
|
28
|
+
if iso.endswith("+00:00"):
|
|
29
|
+
return iso[:-6] + "Z"
|
|
30
|
+
# If no timezone info, append Z (assumed UTC)
|
|
31
|
+
if not iso.endswith("Z") and "+" not in iso and iso.count("-") <= 2:
|
|
32
|
+
return iso + "Z"
|
|
33
|
+
return iso
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_utc_datetime(s: str) -> datetime:
|
|
37
|
+
"""Parse an ISO 8601 string into a timezone-aware UTC datetime.
|
|
38
|
+
|
|
39
|
+
Handles trailing 'Z', '+00:00', and naive strings (assumed UTC).
|
|
40
|
+
"""
|
|
41
|
+
if s.endswith("Z"):
|
|
42
|
+
s = s[:-1]
|
|
43
|
+
dt = datetime.fromisoformat(s)
|
|
44
|
+
if dt.tzinfo is None:
|
|
45
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
46
|
+
return dt
|
|
47
|
+
|
|
48
|
+
|
|
20
49
|
# -----------------------------------------------------------------------------
|
|
21
50
|
# Supporting Types
|
|
22
51
|
# -----------------------------------------------------------------------------
|
|
@@ -311,7 +340,7 @@ class EpisodeTrace:
|
|
|
311
340
|
result = {
|
|
312
341
|
"id": self.id,
|
|
313
342
|
"task_id": self.task_id,
|
|
314
|
-
"timestamp": self.timestamp
|
|
343
|
+
"timestamp": _to_utc_isoformat(self.timestamp),
|
|
315
344
|
"duration_seconds": self.duration_seconds,
|
|
316
345
|
"agent": self.agent,
|
|
317
346
|
"context": {
|
|
@@ -331,7 +360,7 @@ class EpisodeTrace:
|
|
|
331
360
|
"access_count": self.access_count,
|
|
332
361
|
}
|
|
333
362
|
if self.last_accessed:
|
|
334
|
-
result["last_accessed"] = self.last_accessed
|
|
363
|
+
result["last_accessed"] = _to_utc_isoformat(self.last_accessed)
|
|
335
364
|
return result
|
|
336
365
|
|
|
337
366
|
@classmethod
|
|
@@ -339,24 +368,25 @@ class EpisodeTrace:
|
|
|
339
368
|
"""Create from dictionary."""
|
|
340
369
|
context = data.get("context", {})
|
|
341
370
|
timestamp_str = data.get("timestamp", "")
|
|
342
|
-
if isinstance(timestamp_str, str):
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
timestamp_str = timestamp_str[:-1]
|
|
346
|
-
timestamp = datetime.fromisoformat(timestamp_str)
|
|
347
|
-
else:
|
|
371
|
+
if isinstance(timestamp_str, str) and timestamp_str:
|
|
372
|
+
timestamp = _parse_utc_datetime(timestamp_str)
|
|
373
|
+
elif isinstance(timestamp_str, datetime):
|
|
348
374
|
timestamp = timestamp_str
|
|
375
|
+
if timestamp.tzinfo is None:
|
|
376
|
+
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
|
377
|
+
else:
|
|
378
|
+
timestamp = datetime.now(timezone.utc)
|
|
349
379
|
|
|
350
380
|
# Parse last_accessed datetime
|
|
351
381
|
last_accessed = None
|
|
352
382
|
last_accessed_str = data.get("last_accessed")
|
|
353
383
|
if last_accessed_str:
|
|
354
384
|
if isinstance(last_accessed_str, str):
|
|
355
|
-
|
|
356
|
-
last_accessed_str = last_accessed_str[:-1]
|
|
357
|
-
last_accessed = datetime.fromisoformat(last_accessed_str)
|
|
385
|
+
last_accessed = _parse_utc_datetime(last_accessed_str)
|
|
358
386
|
else:
|
|
359
387
|
last_accessed = last_accessed_str
|
|
388
|
+
if hasattr(last_accessed, 'tzinfo') and last_accessed.tzinfo is None:
|
|
389
|
+
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
360
390
|
|
|
361
391
|
return cls(
|
|
362
392
|
id=data.get("id", ""),
|
|
@@ -506,9 +536,9 @@ class SemanticPattern:
|
|
|
506
536
|
"access_count": self.access_count,
|
|
507
537
|
}
|
|
508
538
|
if self.last_used:
|
|
509
|
-
result["last_used"] = self.last_used
|
|
539
|
+
result["last_used"] = _to_utc_isoformat(self.last_used)
|
|
510
540
|
if self.last_accessed:
|
|
511
|
-
result["last_accessed"] = self.last_accessed
|
|
541
|
+
result["last_accessed"] = _to_utc_isoformat(self.last_accessed)
|
|
512
542
|
return result
|
|
513
543
|
|
|
514
544
|
@classmethod
|
|
@@ -518,17 +548,21 @@ class SemanticPattern:
|
|
|
518
548
|
if data.get("last_used"):
|
|
519
549
|
last_used_str = data["last_used"]
|
|
520
550
|
if isinstance(last_used_str, str):
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
last_used =
|
|
551
|
+
last_used = _parse_utc_datetime(last_used_str)
|
|
552
|
+
elif isinstance(last_used_str, datetime):
|
|
553
|
+
last_used = last_used_str
|
|
554
|
+
if last_used.tzinfo is None:
|
|
555
|
+
last_used = last_used.replace(tzinfo=timezone.utc)
|
|
524
556
|
|
|
525
557
|
last_accessed = None
|
|
526
558
|
if data.get("last_accessed"):
|
|
527
559
|
last_accessed_str = data["last_accessed"]
|
|
528
560
|
if isinstance(last_accessed_str, str):
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
last_accessed =
|
|
561
|
+
last_accessed = _parse_utc_datetime(last_accessed_str)
|
|
562
|
+
elif isinstance(last_accessed_str, datetime):
|
|
563
|
+
last_accessed = last_accessed_str
|
|
564
|
+
if last_accessed.tzinfo is None:
|
|
565
|
+
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
532
566
|
|
|
533
567
|
return cls(
|
|
534
568
|
id=data.get("id", ""),
|
|
@@ -661,7 +695,7 @@ class ProceduralSkill:
|
|
|
661
695
|
if self.example_usage:
|
|
662
696
|
result["example_usage"] = self.example_usage
|
|
663
697
|
if self.last_accessed:
|
|
664
|
-
result["last_accessed"] = self.last_accessed
|
|
698
|
+
result["last_accessed"] = _to_utc_isoformat(self.last_accessed)
|
|
665
699
|
return result
|
|
666
700
|
|
|
667
701
|
@classmethod
|
|
@@ -671,9 +705,11 @@ class ProceduralSkill:
|
|
|
671
705
|
if data.get("last_accessed"):
|
|
672
706
|
last_accessed_str = data["last_accessed"]
|
|
673
707
|
if isinstance(last_accessed_str, str):
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
last_accessed =
|
|
708
|
+
last_accessed = _parse_utc_datetime(last_accessed_str)
|
|
709
|
+
elif isinstance(last_accessed_str, datetime):
|
|
710
|
+
last_accessed = last_accessed_str
|
|
711
|
+
if last_accessed.tzinfo is None:
|
|
712
|
+
last_accessed = last_accessed.replace(tzinfo=timezone.utc)
|
|
677
713
|
|
|
678
714
|
return cls(
|
|
679
715
|
id=data.get("id", ""),
|
package/memory/storage.py
CHANGED
|
@@ -303,17 +303,20 @@ class MemoryStorage:
|
|
|
303
303
|
|
|
304
304
|
return episode_id
|
|
305
305
|
|
|
306
|
-
def load_episode(self, episode_id: str) -> Optional[
|
|
306
|
+
def load_episode(self, episode_id: str) -> Optional[dict]:
|
|
307
307
|
"""
|
|
308
308
|
Load an episode trace by ID.
|
|
309
309
|
|
|
310
310
|
Searches across all date directories.
|
|
311
311
|
|
|
312
|
+
Note: Returns a raw dict, not an EpisodeTrace object.
|
|
313
|
+
Callers should convert via EpisodeTrace.from_dict() if needed.
|
|
314
|
+
|
|
312
315
|
Args:
|
|
313
316
|
episode_id: The episode ID to load
|
|
314
317
|
|
|
315
318
|
Returns:
|
|
316
|
-
|
|
319
|
+
dict or None if not found
|
|
317
320
|
"""
|
|
318
321
|
episodic_dir = self.base_path / "episodic"
|
|
319
322
|
if not episodic_dir.exists():
|
|
@@ -494,15 +497,18 @@ class MemoryStorage:
|
|
|
494
497
|
|
|
495
498
|
return pattern_id
|
|
496
499
|
|
|
497
|
-
def load_pattern(self, pattern_id: str) -> Optional[
|
|
500
|
+
def load_pattern(self, pattern_id: str) -> Optional[dict]:
|
|
498
501
|
"""
|
|
499
502
|
Load a semantic pattern by ID.
|
|
500
503
|
|
|
504
|
+
Note: Returns a raw dict, not a SemanticPattern object.
|
|
505
|
+
Callers should convert via SemanticPattern.from_dict() if needed.
|
|
506
|
+
|
|
501
507
|
Args:
|
|
502
508
|
pattern_id: The pattern ID to load
|
|
503
509
|
|
|
504
510
|
Returns:
|
|
505
|
-
|
|
511
|
+
dict or None if not found
|
|
506
512
|
"""
|
|
507
513
|
patterns_path = self.base_path / "semantic" / "patterns.json"
|
|
508
514
|
patterns_file = self._load_json(patterns_path)
|
|
@@ -646,15 +652,18 @@ class MemoryStorage:
|
|
|
646
652
|
|
|
647
653
|
return skill_id
|
|
648
654
|
|
|
649
|
-
def load_skill(self, skill_id: str) -> Optional[
|
|
655
|
+
def load_skill(self, skill_id: str) -> Optional[dict]:
|
|
650
656
|
"""
|
|
651
657
|
Load a procedural skill by ID.
|
|
652
658
|
|
|
659
|
+
Note: Returns a raw dict, not a ProceduralSkill object.
|
|
660
|
+
Callers should convert via ProceduralSkill.from_dict() if needed.
|
|
661
|
+
|
|
653
662
|
Args:
|
|
654
663
|
skill_id: The skill ID to load
|
|
655
664
|
|
|
656
665
|
Returns:
|
|
657
|
-
|
|
666
|
+
dict or None if not found
|
|
658
667
|
"""
|
|
659
668
|
skills_dir = self.base_path / "skills"
|
|
660
669
|
if not skills_dir.exists():
|
package/memory/vector_index.py
CHANGED
|
@@ -7,12 +7,18 @@ No FAISS dependency required - uses pure numpy for cosine similarity.
|
|
|
7
7
|
This module provides efficient vector storage and retrieval for the
|
|
8
8
|
memory system's embedding-based search capabilities.
|
|
9
9
|
"""
|
|
10
|
+
from __future__ import annotations
|
|
10
11
|
|
|
11
12
|
import json
|
|
12
13
|
import os
|
|
13
14
|
from typing import Callable, Dict, List, Optional, Tuple
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
try:
|
|
17
|
+
import numpy as np
|
|
18
|
+
NUMPY_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
np = None # type: ignore
|
|
21
|
+
NUMPY_AVAILABLE = False
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
class VectorIndex:
|