superlocalmemory 3.8.3 → 3.8.6
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/CHANGELOG.md +76 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -20,7 +20,7 @@ from typing import Callable
|
|
|
20
20
|
from mcp.types import ToolAnnotations
|
|
21
21
|
|
|
22
22
|
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
23
|
-
from superlocalmemory.infra.data_root import
|
|
23
|
+
from superlocalmemory.infra.data_root import state_path
|
|
24
24
|
from superlocalmemory.mcp.shared import authorize_mcp_mutation
|
|
25
25
|
|
|
26
26
|
logger = logging.getLogger(__name__)
|
|
@@ -58,67 +58,6 @@ def _emit_event(event_type: str, payload: dict | None = None,
|
|
|
58
58
|
pass
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def _record_recall_hits(
|
|
62
|
-
get_engine: Callable,
|
|
63
|
-
query: str,
|
|
64
|
-
results: list[dict],
|
|
65
|
-
*,
|
|
66
|
-
profile_id: str = "",
|
|
67
|
-
query_id: str = "",
|
|
68
|
-
fact_ids_candidates: list[str] | None = None,
|
|
69
|
-
) -> None:
|
|
70
|
-
"""Record honest shown-state signals (LLD-02 §4.9).
|
|
71
|
-
|
|
72
|
-
v3.4.22: No more fake positives. For every candidate we enqueue a
|
|
73
|
-
``shown`` / ``not_shown`` flip based on whether it was returned in the
|
|
74
|
-
top-K presented to the user. Outcome/reward arrives in v3.4.22 via the
|
|
75
|
-
action-outcomes pipeline.
|
|
76
|
-
|
|
77
|
-
Non-blocking: all work funnels through ``signals.enqueue_shown_flip``
|
|
78
|
-
(module-level queue + background drain). Failures are swallowed —
|
|
79
|
-
signal quality is never load-bearing on recall correctness.
|
|
80
|
-
"""
|
|
81
|
-
try:
|
|
82
|
-
from superlocalmemory.learning.signals import (
|
|
83
|
-
LearningSignals,
|
|
84
|
-
enqueue_shown_flip,
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
pid = profile_id
|
|
88
|
-
if not pid:
|
|
89
|
-
pid = get_engine().profile_id
|
|
90
|
-
slm_dir = canonical_data_root()
|
|
91
|
-
|
|
92
|
-
shown_ids = [r.get("fact_id", "") for r in results[:10]
|
|
93
|
-
if r.get("fact_id")]
|
|
94
|
-
candidates = (fact_ids_candidates
|
|
95
|
-
if fact_ids_candidates is not None
|
|
96
|
-
else shown_ids)
|
|
97
|
-
if not candidates:
|
|
98
|
-
return
|
|
99
|
-
|
|
100
|
-
# Shown-flip enqueue per §4.9. No synthetic positives.
|
|
101
|
-
shown_set = set(shown_ids)
|
|
102
|
-
if query_id:
|
|
103
|
-
for fid in candidates:
|
|
104
|
-
enqueue_shown_flip(query_id, fid, shown=(fid in shown_set))
|
|
105
|
-
|
|
106
|
-
# Legacy zero-cost signals — unchanged (co-retrieval + confidence).
|
|
107
|
-
try:
|
|
108
|
-
signals = LearningSignals(slm_dir / "learning.db")
|
|
109
|
-
signals.record_co_retrieval(pid, shown_ids)
|
|
110
|
-
except Exception:
|
|
111
|
-
pass
|
|
112
|
-
try:
|
|
113
|
-
mem_db = str(slm_dir / "memory.db")
|
|
114
|
-
for fid in shown_ids[:5]:
|
|
115
|
-
LearningSignals.boost_confidence(mem_db, fid)
|
|
116
|
-
except Exception:
|
|
117
|
-
pass
|
|
118
|
-
except Exception:
|
|
119
|
-
pass
|
|
120
|
-
|
|
121
|
-
|
|
122
61
|
def register_core_tools(server, get_engine: Callable) -> None:
|
|
123
62
|
"""Register the 13 core MCP tools on *server*."""
|
|
124
63
|
|
|
@@ -212,10 +151,10 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
212
151
|
await _asyncio.sleep(0.05 * (attempt + 1))
|
|
213
152
|
return {
|
|
214
153
|
"success": False,
|
|
154
|
+
"code": "DAEMON_UNAVAILABLE",
|
|
215
155
|
"retryable": True,
|
|
216
156
|
"error": (
|
|
217
|
-
"
|
|
218
|
-
"same remember operation without starting a second writer."
|
|
157
|
+
"DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later."
|
|
219
158
|
),
|
|
220
159
|
}
|
|
221
160
|
except Exception as dexc:
|
|
@@ -241,11 +180,22 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
241
180
|
worker_meta,
|
|
242
181
|
)
|
|
243
182
|
if not isinstance(stored, dict) or not stored.get("ok"):
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
183
|
+
if isinstance(stored, dict) and stored.get("code") == "DAEMON_UNAVAILABLE":
|
|
184
|
+
return {
|
|
185
|
+
"success": False,
|
|
186
|
+
"code": "DAEMON_UNAVAILABLE",
|
|
187
|
+
"retryable": True,
|
|
188
|
+
"error": stored.get(
|
|
189
|
+
"error",
|
|
190
|
+
"DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
|
|
191
|
+
),
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
"success": False,
|
|
195
|
+
"code": "DAEMON_UNAVAILABLE",
|
|
196
|
+
"retryable": True,
|
|
197
|
+
"error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
|
|
198
|
+
}
|
|
249
199
|
fact_ids = list(stored.get("fact_ids") or [])
|
|
250
200
|
materialization_state = str(
|
|
251
201
|
stored.get("materialization_state") or "complete"
|
|
@@ -275,9 +225,14 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
275
225
|
else "Queryable now; canonical enrichment is still running."
|
|
276
226
|
),
|
|
277
227
|
}
|
|
278
|
-
except Exception
|
|
228
|
+
except Exception:
|
|
279
229
|
logger.exception("remember failed")
|
|
280
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
"success": False,
|
|
232
|
+
"code": "DAEMON_UNAVAILABLE",
|
|
233
|
+
"retryable": True,
|
|
234
|
+
"error": "DAEMON_UNAVAILABLE: owned daemon is unavailable; retry later.",
|
|
235
|
+
}
|
|
281
236
|
|
|
282
237
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
283
238
|
async def recall(
|
|
@@ -378,22 +333,6 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
378
333
|
window=window or None,
|
|
379
334
|
)
|
|
380
335
|
if result.get("ok"):
|
|
381
|
-
# Record implicit feedback: every returned result is a recall_hit
|
|
382
|
-
try:
|
|
383
|
-
_record_recall_hits(
|
|
384
|
-
get_engine,
|
|
385
|
-
query,
|
|
386
|
-
result.get("results", []),
|
|
387
|
-
profile_id=str(result.get("profile", "")),
|
|
388
|
-
)
|
|
389
|
-
except Exception:
|
|
390
|
-
pass # Feedback is non-critical, never block recall
|
|
391
|
-
_emit_event("memory.recalled", {
|
|
392
|
-
"query": query[:80],
|
|
393
|
-
"result_count": result.get("result_count", 0),
|
|
394
|
-
"query_type": result.get("query_type", "unknown"),
|
|
395
|
-
"agent_id": agent_id,
|
|
396
|
-
}, source_agent=agent_id)
|
|
397
336
|
return {
|
|
398
337
|
"success": True,
|
|
399
338
|
"results": result.get("results", []),
|
|
@@ -16,13 +16,11 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
|
-
import sqlite3
|
|
20
|
-
from datetime import datetime, timezone
|
|
21
|
-
from pathlib import Path
|
|
22
19
|
from typing import Callable
|
|
23
20
|
|
|
24
21
|
from mcp.types import ToolAnnotations
|
|
25
22
|
from superlocalmemory.infra.data_root import state_path
|
|
23
|
+
from superlocalmemory.storage.read_connection import ReadConnectionFactory
|
|
26
24
|
|
|
27
25
|
logger = logging.getLogger(__name__)
|
|
28
26
|
|
|
@@ -146,10 +144,8 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
|
|
|
146
144
|
try:
|
|
147
145
|
engine = get_engine()
|
|
148
146
|
profile_id = engine.profile_id if engine else "default"
|
|
149
|
-
db_path =
|
|
150
|
-
|
|
151
|
-
conn = sqlite3.connect(db_path, timeout=10)
|
|
152
|
-
conn.row_factory = sqlite3.Row
|
|
147
|
+
db_path = state_path("memory.db")
|
|
148
|
+
conn = ReadConnectionFactory(db_path).open()
|
|
153
149
|
|
|
154
150
|
# Gather per-skill invocation stats from tool_events
|
|
155
151
|
# Skills are logged as tool_name='Skill' with actual skill name in input_summary
|
|
@@ -274,9 +270,8 @@ def register_evolution_tools(server, get_engine: Callable) -> None:
|
|
|
274
270
|
skill_name: Specific skill name (empty = all skills)
|
|
275
271
|
"""
|
|
276
272
|
try:
|
|
277
|
-
db_path =
|
|
278
|
-
conn =
|
|
279
|
-
conn.row_factory = sqlite3.Row
|
|
273
|
+
db_path = state_path("memory.db")
|
|
274
|
+
conn = ReadConnectionFactory(db_path).open()
|
|
280
275
|
|
|
281
276
|
if skill_name:
|
|
282
277
|
rows = conn.execute(
|
|
@@ -9,7 +9,8 @@ Activation: set ``SLM_OPTIMIZE_CAPTURE=1`` in the daemon's environment. When on:
|
|
|
9
9
|
at load time (see server._load_hooks), so capture never observes a mutated
|
|
10
10
|
request or a cache hit; every line is a genuine upstream exchange.
|
|
11
11
|
* each completed exchange is appended as one JSON line to
|
|
12
|
-
``~/.superlocalmemory/optimize_capture.jsonl`` (0600
|
|
12
|
+
``~/.superlocalmemory/optimize_capture.jsonl`` (owner-only: POSIX 0600 or
|
|
13
|
+
Windows owner DACL, gitignored).
|
|
13
14
|
|
|
14
15
|
ISOLATION GUARANTEE: this module writes ONLY to optimize_capture.jsonl. It never
|
|
15
16
|
opens memory.db, llmcache.db, or any SLM memory store. (Plan §9 hard rule.)
|
|
@@ -21,9 +22,11 @@ swallowed — it MUST NOT break the proxied request the user is waiting on.
|
|
|
21
22
|
from __future__ import annotations
|
|
22
23
|
|
|
23
24
|
import asyncio
|
|
25
|
+
import errno
|
|
24
26
|
import json
|
|
25
27
|
import logging
|
|
26
28
|
import os
|
|
29
|
+
import stat
|
|
27
30
|
import threading
|
|
28
31
|
from pathlib import Path
|
|
29
32
|
from typing import Any
|
|
@@ -55,6 +58,180 @@ def _capture_path() -> Path:
|
|
|
55
58
|
return state_path(_CAPTURE_FILENAME)
|
|
56
59
|
|
|
57
60
|
|
|
61
|
+
def _is_windows() -> bool:
|
|
62
|
+
"""Return whether the running platform uses Windows DACLs."""
|
|
63
|
+
return os.name == "nt"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_link_or_reparse(info: os.stat_result) -> bool:
|
|
67
|
+
"""Reject POSIX symlinks and Windows reparse points before capture."""
|
|
68
|
+
reparse = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
69
|
+
attributes = getattr(info, "st_file_attributes", 0)
|
|
70
|
+
return stat.S_ISLNK(info.st_mode) or bool(reparse and attributes & reparse)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _windows_owner_dacl(
|
|
74
|
+
win32api: Any,
|
|
75
|
+
win32con: Any,
|
|
76
|
+
win32security: Any,
|
|
77
|
+
) -> Any:
|
|
78
|
+
"""Build one protected owner-only DACL for a Windows capture file."""
|
|
79
|
+
import ntsecuritycon
|
|
80
|
+
|
|
81
|
+
token = win32security.OpenProcessToken(
|
|
82
|
+
win32api.GetCurrentProcess(),
|
|
83
|
+
win32con.TOKEN_QUERY,
|
|
84
|
+
)
|
|
85
|
+
try:
|
|
86
|
+
owner_sid = win32security.GetTokenInformation(
|
|
87
|
+
token,
|
|
88
|
+
win32security.TokenUser,
|
|
89
|
+
)[0]
|
|
90
|
+
finally:
|
|
91
|
+
token.Close()
|
|
92
|
+
dacl = win32security.ACL()
|
|
93
|
+
dacl.AddAccessAllowedAce(
|
|
94
|
+
win32security.ACL_REVISION,
|
|
95
|
+
ntsecuritycon.FILE_ALL_ACCESS,
|
|
96
|
+
owner_sid,
|
|
97
|
+
)
|
|
98
|
+
return dacl
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _open_windows_capture_append(path: Path) -> int:
|
|
102
|
+
"""Create/open a Windows file with append + WRITE_DAC on one handle."""
|
|
103
|
+
try:
|
|
104
|
+
import msvcrt
|
|
105
|
+
|
|
106
|
+
import ntsecuritycon
|
|
107
|
+
import win32api
|
|
108
|
+
import win32con
|
|
109
|
+
import win32file
|
|
110
|
+
import win32security
|
|
111
|
+
except ImportError as exc:
|
|
112
|
+
raise OSError("Windows capture ACL support is unavailable") from exc
|
|
113
|
+
|
|
114
|
+
handle = None
|
|
115
|
+
try:
|
|
116
|
+
# A creation-time descriptor prevents a newly created capture file from
|
|
117
|
+
# ever inheriting a broader parent DACL. For an existing file Windows
|
|
118
|
+
# ignores this descriptor; _enforce_owner_only_permissions replaces
|
|
119
|
+
# that DACL through the same WRITE_DAC-capable handle before writing.
|
|
120
|
+
dacl = _windows_owner_dacl(win32api, win32con, win32security)
|
|
121
|
+
security_attributes = win32security.SECURITY_ATTRIBUTES()
|
|
122
|
+
security_attributes.bInheritHandle = False
|
|
123
|
+
security_attributes.SECURITY_DESCRIPTOR.SetSecurityDescriptorDacl(
|
|
124
|
+
1,
|
|
125
|
+
dacl,
|
|
126
|
+
0,
|
|
127
|
+
)
|
|
128
|
+
security_attributes.SECURITY_DESCRIPTOR.SetSecurityDescriptorControl(
|
|
129
|
+
win32security.SE_DACL_PROTECTED,
|
|
130
|
+
win32security.SE_DACL_PROTECTED,
|
|
131
|
+
)
|
|
132
|
+
handle = win32file.CreateFile(
|
|
133
|
+
os.fspath(path),
|
|
134
|
+
ntsecuritycon.FILE_APPEND_DATA | ntsecuritycon.WRITE_DAC,
|
|
135
|
+
win32con.FILE_SHARE_READ
|
|
136
|
+
| win32con.FILE_SHARE_WRITE
|
|
137
|
+
| win32con.FILE_SHARE_DELETE,
|
|
138
|
+
security_attributes,
|
|
139
|
+
win32con.OPEN_ALWAYS,
|
|
140
|
+
win32con.FILE_ATTRIBUTE_NORMAL
|
|
141
|
+
# pywin32 does not export this SDK constant from win32con on
|
|
142
|
+
# every supported Python build. Keep the Microsoft-defined value
|
|
143
|
+
# as a named fallback rather than silently following a reparse.
|
|
144
|
+
| getattr(win32con, "FILE_FLAG_OPEN_REPARSE_POINT", 0x00200000),
|
|
145
|
+
None,
|
|
146
|
+
)
|
|
147
|
+
file_info = win32file.GetFileInformationByHandle(handle)
|
|
148
|
+
if file_info[0] & win32con.FILE_ATTRIBUTE_REPARSE_POINT:
|
|
149
|
+
raise OSError(
|
|
150
|
+
errno.ELOOP,
|
|
151
|
+
"capture path is a Windows reparse point",
|
|
152
|
+
path,
|
|
153
|
+
)
|
|
154
|
+
try:
|
|
155
|
+
# Enforce the DACL while this is still the original CreateFile
|
|
156
|
+
# handle carrying WRITE_DAC. Transferring it into Python's CRT
|
|
157
|
+
# first can lose the authority SetSecurityInfo needs on Windows.
|
|
158
|
+
win32security.SetSecurityInfo(
|
|
159
|
+
handle,
|
|
160
|
+
win32security.SE_FILE_OBJECT,
|
|
161
|
+
win32security.DACL_SECURITY_INFORMATION
|
|
162
|
+
| win32security.PROTECTED_DACL_SECURITY_INFORMATION,
|
|
163
|
+
None,
|
|
164
|
+
None,
|
|
165
|
+
dacl,
|
|
166
|
+
None,
|
|
167
|
+
)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
raise OSError(
|
|
170
|
+
"Windows capture ACL could not be enforced "
|
|
171
|
+
f"({type(exc).__name__}: {exc})"
|
|
172
|
+
) from exc
|
|
173
|
+
|
|
174
|
+
# Transfer the native handle to Python's CRT descriptor exactly once.
|
|
175
|
+
raw_handle = handle.Detach()
|
|
176
|
+
handle = None
|
|
177
|
+
try:
|
|
178
|
+
return msvcrt.open_osfhandle(
|
|
179
|
+
raw_handle,
|
|
180
|
+
os.O_WRONLY | os.O_APPEND | getattr(os, "O_BINARY", 0),
|
|
181
|
+
)
|
|
182
|
+
except BaseException:
|
|
183
|
+
win32api.CloseHandle(raw_handle)
|
|
184
|
+
raise
|
|
185
|
+
except OSError:
|
|
186
|
+
raise
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
raise OSError(f"Windows secure capture open failed: {exc}") from exc
|
|
189
|
+
finally:
|
|
190
|
+
if handle is not None:
|
|
191
|
+
handle.Close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _open_capture_append(path: Path) -> int:
|
|
195
|
+
"""Open one append descriptor without following or racing a link target."""
|
|
196
|
+
try:
|
|
197
|
+
before = path.lstat()
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
before = None
|
|
200
|
+
if before is not None and _is_link_or_reparse(before):
|
|
201
|
+
raise OSError(errno.ELOOP, "capture path is a link or reparse point", path)
|
|
202
|
+
|
|
203
|
+
if _is_windows():
|
|
204
|
+
fd = _open_windows_capture_append(path)
|
|
205
|
+
else:
|
|
206
|
+
flags = os.O_CREAT | os.O_WRONLY | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
|
|
207
|
+
fd = os.open(path, flags, 0o600)
|
|
208
|
+
try:
|
|
209
|
+
opened = os.fstat(fd)
|
|
210
|
+
current = path.lstat()
|
|
211
|
+
if (
|
|
212
|
+
_is_link_or_reparse(current)
|
|
213
|
+
or (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino)
|
|
214
|
+
):
|
|
215
|
+
raise OSError(
|
|
216
|
+
errno.ELOOP,
|
|
217
|
+
"capture path changed during secure open",
|
|
218
|
+
path,
|
|
219
|
+
)
|
|
220
|
+
except BaseException:
|
|
221
|
+
os.close(fd)
|
|
222
|
+
raise
|
|
223
|
+
return fd
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _enforce_owner_only_permissions(fd: int) -> None:
|
|
227
|
+
"""Apply owner-only access control through the already-verified handle."""
|
|
228
|
+
if not _is_windows():
|
|
229
|
+
os.fchmod(fd, 0o600)
|
|
230
|
+
return
|
|
231
|
+
# _open_windows_capture_append applies the protected DACL on the original
|
|
232
|
+
# WRITE_DAC-capable CreateFile handle before CRT descriptor transfer.
|
|
233
|
+
|
|
234
|
+
|
|
58
235
|
class ShadowCapture:
|
|
59
236
|
"""Thread-safe append-only JSONL writer for proxy exchanges (singleton)."""
|
|
60
237
|
|
|
@@ -95,10 +272,10 @@ class ShadowCapture:
|
|
|
95
272
|
Fail-open: any error is logged and False is returned; never raised.
|
|
96
273
|
|
|
97
274
|
Security: opens with a single ``os.open`` carrying ``O_CREAT |
|
|
98
|
-
O_APPEND
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
275
|
+
O_APPEND`` and mode ``0o600`` on every write. POSIX adds O_NOFOLLOW;
|
|
276
|
+
all platforms reject link/reparse metadata and verify that the opened
|
|
277
|
+
descriptor still identifies the current path. Permissions are then
|
|
278
|
+
enforced through that verified descriptor before any data is written.
|
|
102
279
|
"""
|
|
103
280
|
try:
|
|
104
281
|
line = json.dumps(entry, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -109,9 +286,20 @@ class ShadowCapture:
|
|
|
109
286
|
try:
|
|
110
287
|
with self._write_lock:
|
|
111
288
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
289
|
+
fd = _open_capture_append(self._path)
|
|
290
|
+
try:
|
|
291
|
+
# Enforce the ACL through the verified descriptor so a
|
|
292
|
+
# pathname swap cannot redirect chmod/icacls elsewhere.
|
|
293
|
+
_enforce_owner_only_permissions(fd)
|
|
294
|
+
# fdopen transfers descriptor ownership only after it
|
|
295
|
+
# returns successfully. Keep this conversion inside the
|
|
296
|
+
# explicit-close boundary so an allocation/codec failure
|
|
297
|
+
# cannot leak one descriptor per captured request.
|
|
298
|
+
fh = os.fdopen(fd, "a", encoding="utf-8")
|
|
299
|
+
except BaseException:
|
|
300
|
+
os.close(fd)
|
|
301
|
+
raise
|
|
302
|
+
with fh:
|
|
115
303
|
fh.write(line + "\n")
|
|
116
304
|
self._count += 1
|
|
117
305
|
return True
|
|
@@ -904,13 +904,18 @@ class RetrievalEngine:
|
|
|
904
904
|
|
|
905
905
|
return out
|
|
906
906
|
|
|
907
|
-
def close(self) -> None:
|
|
908
|
-
"""Release
|
|
907
|
+
def close(self, *, wait: bool = False) -> None:
|
|
908
|
+
"""Release owned channel workers without blocking daemon shutdown.
|
|
909
|
+
|
|
910
|
+
Active channel calls have their own response deadline. Waiting here
|
|
911
|
+
can still deadlock shutdown when an extension ignores that deadline,
|
|
912
|
+
so the daemon uses the executor's non-blocking cancellation path.
|
|
913
|
+
"""
|
|
909
914
|
with self._close_lock:
|
|
910
915
|
if self._closed:
|
|
911
916
|
return
|
|
912
917
|
self._closed = True
|
|
913
|
-
self._channel_executor.shutdown(wait=
|
|
918
|
+
self._channel_executor.shutdown(wait=wait, cancel_futures=True)
|
|
914
919
|
|
|
915
920
|
# -- Fact loading -------------------------------------------------------
|
|
916
921
|
|
|
@@ -955,7 +960,13 @@ class RetrievalEngine:
|
|
|
955
960
|
Blended: alpha * sigmoid(CE_score) + (1 - alpha) * rrf_score.
|
|
956
961
|
Speaker tags stripped before scoring (Bug 3 fix).
|
|
957
962
|
"""
|
|
958
|
-
# Bug 2 fix: score ALL candidates, not just top_k
|
|
963
|
+
# Bug 2 fix: score ALL candidates, not just top_k. v3.8.5: verified on
|
|
964
|
+
# the real DB that bounding the CE to the top-N fusion candidates both
|
|
965
|
+
# (a) gave NO latency win (the cross-encoder batches all pairs in one
|
|
966
|
+
# forward pass, so 60 vs 184 pairs is within noise) and (b) CHANGED the
|
|
967
|
+
# top-5 on 4/8 queries — the CE legitimately promotes items ranked below
|
|
968
|
+
# the fusion top-N into the answer. So exhaustive reranking stays: it is
|
|
969
|
+
# a quality feature, not the latency bottleneck.
|
|
959
970
|
candidates = [
|
|
960
971
|
(fact_map[fr.fact_id], fr.fused_score)
|
|
961
972
|
for fr in fused if fr.fact_id in fact_map
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import json
|
|
16
16
|
import logging
|
|
17
|
+
import os
|
|
17
18
|
import re
|
|
18
19
|
import threading
|
|
19
20
|
from collections import defaultdict
|
|
@@ -31,6 +32,24 @@ if TYPE_CHECKING:
|
|
|
31
32
|
|
|
32
33
|
logger = logging.getLogger(__name__)
|
|
33
34
|
|
|
35
|
+
|
|
36
|
+
def _adj_ttl_seconds() -> float:
|
|
37
|
+
"""In-memory adjacency-cache TTL (seconds), env-overridable.
|
|
38
|
+
|
|
39
|
+
v3.8.5: raised from a hard-coded 300s to 3600s. The TTL only exists to
|
|
40
|
+
catch edge-WEIGHT mutations (pruning / MAX-merge) that leave the edge COUNT
|
|
41
|
+
unchanged — new memories already force a reload via the count check. At 300s
|
|
42
|
+
a 208K-edge graph rebuilt on the recall hot path every 5 idle minutes,
|
|
43
|
+
causing a recurring multi-second latency spike. Weight drift is a minor
|
|
44
|
+
ranking refinement, so a longer TTL trades negligible staleness for a big
|
|
45
|
+
latency win. Set SLM_ENTITY_ADJ_TTL_S to tune (0 disables time-based reload;
|
|
46
|
+
the count-based correctness reload always remains).
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
return max(0.0, float(os.environ.get("SLM_ENTITY_ADJ_TTL_S", "3600")))
|
|
50
|
+
except (TypeError, ValueError):
|
|
51
|
+
return 3600.0
|
|
52
|
+
|
|
34
53
|
_PROPER_NOUN_RE = re.compile(r"\b[A-Z][a-z]{1,}\b")
|
|
35
54
|
|
|
36
55
|
_ENTITY_STOP: frozenset[str] = frozenset({
|
|
@@ -156,7 +175,12 @@ class EntityGraphChannel:
|
|
|
156
175
|
# count-stable window would otherwise serve a stale adjacency map.
|
|
157
176
|
import time as _t_ec
|
|
158
177
|
_now_ec = _t_ec.monotonic()
|
|
159
|
-
|
|
178
|
+
_ttl = _adj_ttl_seconds()
|
|
179
|
+
# TTL=0 disables the time-based reload entirely (count-based correctness
|
|
180
|
+
# reload still applies); otherwise the cache is fresh within the TTL.
|
|
181
|
+
_fresh = _ttl <= 0.0 or (
|
|
182
|
+
(_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < _ttl
|
|
183
|
+
)
|
|
160
184
|
if (self._adj_scope_key == scope_key
|
|
161
185
|
and (self._adj or self._visible_fact_ids)
|
|
162
186
|
and self._adj_edge_count == current_count
|