superlocalmemory 3.8.2 → 3.8.5

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -101,17 +101,30 @@ def _now() -> str:
101
101
  return datetime.now(UTC).isoformat()
102
102
 
103
103
 
104
- def _connect(db_path: Path) -> sqlite3.Connection:
105
- conn = sqlite3.connect(str(db_path), timeout=5)
104
+ def _connect_read(db_path: Path) -> sqlite3.Connection:
105
+ """Read-only connection with system-default busy_timeout.
106
+
107
+ Concurrency fix (v3.8.4): busy_timeout raised from 5 s to match the
108
+ system default (10 s, env SLM_DB_BUSY_TIMEOUT_MS). Timeout raised from
109
+ 5 s to 10 s for the same reason. Write connections now go through
110
+ memory_write() so they acquire get_write_lock() and never use this helper.
111
+ """
112
+ import os
113
+
114
+ try:
115
+ ms = max(0, int(os.environ.get("SLM_DB_BUSY_TIMEOUT_MS", "10000")))
116
+ except (TypeError, ValueError):
117
+ ms = 10000
118
+ conn = sqlite3.connect(str(db_path), timeout=ms / 1000.0)
106
119
  conn.row_factory = sqlite3.Row
107
- conn.execute("PRAGMA busy_timeout=5000")
120
+ conn.execute(f"PRAGMA busy_timeout={ms}")
108
121
  conn.execute("PRAGMA foreign_keys=ON")
109
122
  return conn
110
123
 
111
124
 
112
125
  def get_repair_status(db_path: Path) -> dict[str, int | str]:
113
126
  """Read durable backfill progress without inferring it from schema."""
114
- conn = _connect(Path(db_path))
127
+ conn = _connect_read(Path(db_path))
115
128
  try:
116
129
  row = conn.execute(
117
130
  "SELECT state,target_fact_rowid,last_fact_rowid,scanned,inserted,"
@@ -142,58 +155,70 @@ def _entity_ids(raw: object) -> tuple[str, ...]:
142
155
 
143
156
 
144
157
  def _repair_batch(conn: sqlite3.Connection, batch_size: int) -> dict[str, int | bool]:
145
- conn.execute("BEGIN IMMEDIATE")
146
- try:
147
- status = conn.execute(
148
- "SELECT last_fact_rowid,target_fact_rowid "
149
- "FROM fact_entity_association_repair_state "
150
- "WHERE repair_key='historical-backfill'"
151
- ).fetchone()
152
- cursor = int(status["last_fact_rowid"] or 0)
153
- target = int(status["target_fact_rowid"])
154
- rows = conn.execute(
155
- "SELECT rowid,fact_id,profile_id,canonical_entities_json "
156
- "FROM atomic_facts WHERE rowid>? AND rowid<=? "
157
- "ORDER BY rowid LIMIT ?",
158
- (cursor, target, batch_size),
159
- ).fetchall()
160
- if not rows:
161
- conn.execute(
162
- "UPDATE fact_entity_association_repair_state "
163
- "SET state='complete',last_error='',updated_at=? "
164
- "WHERE repair_key='historical-backfill'",
165
- (_now(),),
166
- )
167
- conn.commit()
168
- return {"scanned": 0, "inserted": 0, "complete": True}
169
- inserted = 0
170
- for row in rows:
171
- for entity_id in _entity_ids(row["canonical_entities_json"]):
172
- result = conn.execute(
173
- "INSERT OR IGNORE INTO fact_entity_associations "
174
- "(profile_id,fact_id,entity_id,first_operation_id,"
175
- "count_applied) "
176
- "SELECT ?,?,?,?,? FROM canonical_entities "
177
- "WHERE profile_id=? AND entity_id=?",
178
- (
179
- row["profile_id"], row["fact_id"], entity_id,
180
- "migration-backfill", 0,
181
- row["profile_id"], entity_id,
182
- ),
183
- )
184
- inserted += max(0, result.rowcount)
158
+ """Execute one backfill batch on *conn*.
159
+
160
+ Concurrency fix (v3.8.4): caller MUST hold the write lock via
161
+ memory_write() before calling this function. The explicit
162
+ ``conn.execute("BEGIN IMMEDIATE")`` has been removed because:
163
+
164
+ * memory_write() already acquired get_write_lock() (Python-level
165
+ serialisation) no other in-process writer can start.
166
+ * SQLite's implicit deferred transaction is promoted to a write
167
+ transaction on the first INSERT, which is equivalent to BEGIN
168
+ IMMEDIATE for in-process serialisation.
169
+ * The explicit BEGIN IMMEDIATE previously bypassed get_write_lock()
170
+ and would retry at the SQLite layer only (busy_timeout=5 s, now
171
+ 10 s) without respecting the Python-level write lock order.
172
+
173
+ Transaction boundary (commit/rollback) is managed by memory_write().
174
+ """
175
+ status = conn.execute(
176
+ "SELECT last_fact_rowid,target_fact_rowid "
177
+ "FROM fact_entity_association_repair_state "
178
+ "WHERE repair_key='historical-backfill'"
179
+ ).fetchone()
180
+ if status is None:
181
+ return {"scanned": 0, "inserted": 0, "complete": True}
182
+ cursor = int(status["last_fact_rowid"] or 0)
183
+ target = int(status["target_fact_rowid"])
184
+ rows = conn.execute(
185
+ "SELECT rowid,fact_id,profile_id,canonical_entities_json "
186
+ "FROM atomic_facts WHERE rowid>? AND rowid<=? "
187
+ "ORDER BY rowid LIMIT ?",
188
+ (cursor, target, batch_size),
189
+ ).fetchall()
190
+ if not rows:
185
191
  conn.execute(
186
- "UPDATE fact_entity_association_repair_state SET "
187
- "state='running',last_fact_rowid=?,scanned=scanned+?,"
188
- "inserted=inserted+?,last_error='',updated_at=? "
192
+ "UPDATE fact_entity_association_repair_state "
193
+ "SET state='complete',last_error='',updated_at=? "
189
194
  "WHERE repair_key='historical-backfill'",
190
- (int(rows[-1]["rowid"]), len(rows), inserted, _now()),
195
+ (_now(),),
191
196
  )
192
- conn.commit()
193
- return {"scanned": len(rows), "inserted": inserted, "complete": False}
194
- except Exception:
195
- conn.rollback()
196
- raise
197
+ return {"scanned": 0, "inserted": 0, "complete": True}
198
+ inserted = 0
199
+ for row in rows:
200
+ for entity_id in _entity_ids(row["canonical_entities_json"]):
201
+ result = conn.execute(
202
+ "INSERT OR IGNORE INTO fact_entity_associations "
203
+ "(profile_id,fact_id,entity_id,first_operation_id,"
204
+ "count_applied) "
205
+ "SELECT ?,?,?,?,? FROM canonical_entities "
206
+ "WHERE profile_id=? AND entity_id=?",
207
+ (
208
+ row["profile_id"], row["fact_id"], entity_id,
209
+ "migration-backfill", 0,
210
+ row["profile_id"], entity_id,
211
+ ),
212
+ )
213
+ inserted += max(0, result.rowcount)
214
+ conn.execute(
215
+ "UPDATE fact_entity_association_repair_state SET "
216
+ "state='running',last_fact_rowid=?,scanned=scanned+?,"
217
+ "inserted=inserted+?,last_error='',updated_at=? "
218
+ "WHERE repair_key='historical-backfill'",
219
+ (int(rows[-1]["rowid"]), len(rows), inserted, _now()),
220
+ )
221
+ return {"scanned": len(rows), "inserted": inserted, "complete": False}
197
222
 
198
223
 
199
224
  def repair_fact_entity_associations(
@@ -202,34 +227,44 @@ def repair_fact_entity_associations(
202
227
  batch_size: int = 250,
203
228
  max_batches: int = 1,
204
229
  ) -> dict[str, int | bool]:
205
- """Run bounded, restartable short-transaction backfill batches."""
230
+ """Run bounded, restartable short-transaction backfill batches.
231
+
232
+ Concurrency fix (v3.8.4): each batch is now wrapped in memory_write()
233
+ which acquires get_write_lock() (process-level write serialisation) and
234
+ sets the system-default busy_timeout (10 s) before opening the SQLite
235
+ connection. Previously the loop used a single raw _connect() (timeout=5,
236
+ busy_timeout=5000) without get_write_lock(), which bypassed in-process
237
+ write serialisation and could cause SQLITE_BUSY after only 5 s.
238
+ """
239
+ from superlocalmemory.storage.memory_write import memory_write
240
+
206
241
  if batch_size < 1 or max_batches < 1:
207
242
  raise ValueError("batch_size and max_batches must be positive")
208
- totals = {"scanned": 0, "inserted": 0, "complete": False}
209
- conn = _connect(Path(db_path))
210
- try:
211
- for _ in range(max_batches):
212
- result = _repair_batch(conn, batch_size)
213
- totals["scanned"] += int(result["scanned"])
214
- totals["inserted"] += int(result["inserted"])
215
- totals["complete"] = bool(result["complete"])
216
- if totals["complete"]:
217
- break
218
- return totals
219
- except sqlite3.Error as exc:
243
+ totals: dict[str, int | bool] = {"scanned": 0, "inserted": 0, "complete": False}
244
+ for _ in range(max_batches):
220
245
  try:
221
- conn.execute(
222
- "UPDATE fact_entity_association_repair_state SET "
223
- "state='retrying',last_error=?,updated_at=? "
224
- "WHERE repair_key='historical-backfill'",
225
- (type(exc).__name__, _now()),
226
- )
227
- conn.commit()
228
- except sqlite3.Error:
229
- pass
230
- raise
231
- finally:
232
- conn.close()
246
+ with memory_write(Path(db_path)) as conn:
247
+ conn.execute("PRAGMA foreign_keys=ON")
248
+ result = _repair_batch(conn, batch_size)
249
+ totals["scanned"] += int(result["scanned"])
250
+ totals["inserted"] += int(result["inserted"])
251
+ totals["complete"] = bool(result["complete"])
252
+ except sqlite3.Error as exc:
253
+ # On error, record the retrying state in a separate short write.
254
+ try:
255
+ with memory_write(Path(db_path)) as econn:
256
+ econn.execute(
257
+ "UPDATE fact_entity_association_repair_state SET "
258
+ "state='retrying',last_error=?,updated_at=? "
259
+ "WHERE repair_key='historical-backfill'",
260
+ (type(exc).__name__, _now()),
261
+ )
262
+ except sqlite3.Error:
263
+ pass
264
+ raise
265
+ if totals["complete"]:
266
+ break
267
+ return totals
233
268
 
234
269
 
235
270
  def verify(conn: sqlite3.Connection) -> bool:
@@ -0,0 +1,80 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """M031 — dead-letter queue for exhausted ingestion operations (Fix E, issue #77).
6
+
7
+ Additive migration: creates dead_letter_operations if not present.
8
+ Existing rows in ingestion_operations are unaffected.
9
+
10
+ When an M018 ingestion operation exhausts _MAX_AUTOMATIC_MATERIALIZATION_ATTEMPTS
11
+ (10) it previously remained silently in FAILED state — invisible to operators and
12
+ unreachable by the materialiser. This table gives operators a persistent,
13
+ inspectable record of every poisoned operation: original content, error, attempt
14
+ count, timestamps, and profile scope.
15
+
16
+ Schema design:
17
+ - original_op_id references ingestion_operations.operation_id (soft ref — no FK
18
+ so that dead-lettered rows survive if the source row is later cleaned up).
19
+ - profile_id allows per-profile DLQ dashboards.
20
+ - dead_lettered_at defaults to the current epoch for point-in-time auditing.
21
+ - No TTL/expiry here — retention policy belongs to a future maintenance sweep.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import sqlite3
27
+
28
+ NAME = "M031_dead_letter_operations"
29
+ DB_TARGET = "memory"
30
+
31
+ DDL = """
32
+ CREATE TABLE IF NOT EXISTS dead_letter_operations (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ original_op_id TEXT NOT NULL,
35
+ operation_type TEXT NOT NULL DEFAULT 'M018',
36
+ content TEXT,
37
+ metadata_json TEXT,
38
+ error TEXT,
39
+ attempt_count INTEGER,
40
+ first_attempt_at REAL,
41
+ dead_lettered_at REAL NOT NULL DEFAULT (unixepoch('now')),
42
+ profile_id TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS idx_dlq_profile
45
+ ON dead_letter_operations (profile_id);
46
+ CREATE INDEX IF NOT EXISTS idx_dlq_op_id
47
+ ON dead_letter_operations (original_op_id);
48
+ """
49
+
50
+
51
+ def apply(conn: sqlite3.Connection) -> None:
52
+ """Create the dead_letter_operations table and indexes idempotently."""
53
+ conn.executescript(DDL)
54
+
55
+
56
+ def verify(conn: sqlite3.Connection) -> bool:
57
+ """Return True only when the complete M031 contract is present."""
58
+ table = conn.execute(
59
+ "SELECT 1 FROM sqlite_master WHERE type='table' "
60
+ "AND name='dead_letter_operations'"
61
+ ).fetchone()
62
+ if table is None:
63
+ return False
64
+ columns = {
65
+ row[1]
66
+ for row in conn.execute(
67
+ "PRAGMA table_info(dead_letter_operations)"
68
+ ).fetchall()
69
+ }
70
+ required = {
71
+ "id",
72
+ "original_op_id",
73
+ "operation_type",
74
+ "content",
75
+ "error",
76
+ "attempt_count",
77
+ "dead_lettered_at",
78
+ "profile_id",
79
+ }
80
+ return required <= columns
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com
4
+
5
+ """Process-level per-path write-lock registry for memory.db.
6
+
7
+ ALL code that writes to a given SQLite file MUST acquire the lock returned
8
+ by ``get_write_lock(db_path)`` BEFORE opening a sqlite3 connection for
9
+ writing. This serialises writes at the Python layer so that only ONE
10
+ sqlite3 connection holds the SQLite WAL write lock at any instant, which
11
+ eliminates all SQLITE_BUSY retries within the process.
12
+
13
+ Invariant
14
+ ---------
15
+ Every memory.db write path in the process acquires the same RLock before
16
+ touching sqlite3. Because SQLite WAL mode allows one writer at a time, a
17
+ Python-level RLock gives us the same serialisation guarantee while
18
+ completely avoiding the SQLITE_BUSY / busy_timeout retry loop.
19
+
20
+ Lock ordering rule (must never be violated)
21
+ -------------------------------------------
22
+ get_write_lock(memory.db) ← OUTERMOST
23
+ ↳ VectorStore._lock (inner — VectorStore in-memory state)
24
+ ↳ sqlite3 write transaction (BEGIN … COMMIT)
25
+
26
+ Re-entrancy
27
+ -----------
28
+ ``threading.RLock`` is used so that the same thread can re-acquire the
29
+ lock without deadlocking. This is required by the self-heal backfill
30
+ pattern::
31
+
32
+ with db._lock: # db._lock IS get_write_lock(memory.db)
33
+ vs.upsert(...) # also calls get_write_lock → re-entrant OK
34
+
35
+ Usage
36
+ -----
37
+ from superlocalmemory.storage.write_lock import get_write_lock
38
+
39
+ with get_write_lock(db_path):
40
+ conn = sqlite3.connect(str(db_path))
41
+ conn.execute("INSERT INTO ...")
42
+ conn.commit()
43
+ conn.close()
44
+ """
45
+ from __future__ import annotations
46
+
47
+ import threading
48
+ from pathlib import Path
49
+
50
+ # Registry: resolved absolute path string → RLock.
51
+ # Protected by its own Lock so the registry itself is thread-safe.
52
+ _registry: dict[str, threading.RLock] = {}
53
+ _registry_lock = threading.Lock()
54
+
55
+
56
+ def get_write_lock(db_path: str | Path) -> threading.RLock:
57
+ """Return the process-level write-serialisation RLock for *db_path*.
58
+
59
+ Resolves the path to its canonical absolute form before hashing so
60
+ that relative paths, ``.`` components, and symlinks all map to the
61
+ same lock as their resolved target. Creates a new ``RLock`` on first
62
+ call for each unique resolved path. Thread-safe.
63
+
64
+ Parameters
65
+ ----------
66
+ db_path:
67
+ Path to the SQLite database file. The file need not exist yet.
68
+
69
+ Returns
70
+ -------
71
+ threading.RLock
72
+ The shared write-serialisation lock for *db_path*. All writers
73
+ of this file in the current process share the same object.
74
+ """
75
+ p = Path(db_path)
76
+ try:
77
+ key = str(p.resolve())
78
+ except OSError:
79
+ # File doesn't exist yet; use absolute path as best-effort key.
80
+ key = str(p.absolute())
81
+
82
+ with _registry_lock:
83
+ if key not in _registry:
84
+ _registry[key] = threading.RLock()
85
+ return _registry[key]
86
+
87
+
88
+ __all__ = ["get_write_lock"]
@@ -13,7 +13,12 @@
13
13
  // surfaces as a normal rejection and the UI can show a clear error.
14
14
  // ============================================================================
15
15
 
16
- window.SLM_FETCH_TIMEOUT_MS = 15000;
16
+ // v3.8.3: 30s (> the server's 25s recall budget) so the browser waits for a
17
+ // quality recall under heavy multi-agent load instead of aborting it early
18
+ // ("signal is aborted without reason"). The server self-bounds recall and
19
+ // falls back to keyword within its budget, so this only ever waits longer for
20
+ // a genuinely stuck request. Per-call init.timeoutMs still overrides.
21
+ window.SLM_FETCH_TIMEOUT_MS = 30000;
17
22
  window.SLM_INSTALL_TOKEN_KEY = 'slm_install_token';
18
23
 
19
24
  // B2 (3.7.9): the install token is kept in a private closure, never in