superlocalmemory 4.1.6 → 4.1.8

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +3 -3
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +1 -1
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/agents/slm-memory-advisor.md +49 -0
  25. package/plugin-src/agents/slm-optimize-advisor.md +44 -0
  26. package/plugin-src/rules/AGENTS.md +1 -1
  27. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-loop/SKILL.md +1 -1
  32. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  33. package/plugin-src/skills/slm-profile/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  36. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  38. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  39. package/pyproject.toml +5 -1
  40. package/src/superlocalmemory/__init__.py +1 -1
  41. package/src/superlocalmemory/cli/host_upgrades.py +21 -7
  42. package/src/superlocalmemory/core/engine.py +7 -1
  43. package/src/superlocalmemory/core/recall_pipeline.py +15 -5
  44. package/src/superlocalmemory/core/session_identity.py +14 -1
  45. package/src/superlocalmemory/hooks/codex_assets.py +165 -45
  46. package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +122 -0
  47. package/src/superlocalmemory/learning/bandit.py +22 -2
  48. package/src/superlocalmemory/learning/engagement_features.py +279 -0
  49. package/src/superlocalmemory/learning/outcome_queue.py +14 -0
  50. package/src/superlocalmemory/learning/propensity.py +131 -0
  51. package/src/superlocalmemory/learning/reward.py +42 -16
  52. package/src/superlocalmemory/learning/reward_model.py +144 -0
  53. package/src/superlocalmemory/learning/reward_proxy.py +148 -22
  54. package/src/superlocalmemory/server/routes/v3_api.py +4 -3
  55. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +22 -0
@@ -34,6 +34,11 @@ from datetime import datetime, timedelta, timezone
34
34
  from pathlib import Path
35
35
  from typing import Any
36
36
 
37
+ from superlocalmemory.learning.engagement_features import (
38
+ EngagementFeatures,
39
+ extract_features,
40
+ )
41
+ from superlocalmemory.learning.reward_model import score
37
42
  from superlocalmemory.core.topic_signature import compute_topic_signature
38
43
  from superlocalmemory.learning.bandit import ContextualBandit
39
44
 
@@ -105,10 +110,10 @@ def _shown_fact_ids(
105
110
  """The fact_ids this play recorded at recall time (M044), or [].
106
111
 
107
112
  Preferred over the ``learning_signals`` lookup below because it does not
108
- depend on the exposure enqueue, which is off: twenty rows per query, and
109
- the source of a 2,675x inflation in the ranking-phase counter. With no
110
- signals rows there was nothing to look for, so every play settled as the
111
- 120-second default and 165 arms sat at alpha == beta.
113
+ depend on the exposure enqueue, which is off because it wrote a row per
114
+ displayed memory per query and badly inflated the ranking-phase counter.
115
+ With no signals rows there is nothing to look for, so plays fell through to
116
+ the age-based default and arms stayed on their priors.
112
117
 
113
118
  Returns [] on a store where M044 has not run — "no such column" is an
114
119
  ``sqlite3.Error`` and is caught, so an unmigrated install falls back to the
@@ -366,6 +371,98 @@ def _default_deadline(
366
371
  )
367
372
 
368
373
 
374
+ #: Once a play is older than this with nothing observed, it is closed without
375
+ #: touching the posterior. Leaving it open forever would make every settler
376
+ #: pass rescan it; settling it at a neutral value is what this module stopped
377
+ #: doing. Closed-and-unjudged is the third option that was missing.
378
+ _ABSTAIN_EXPIRY_SEC = 900
379
+
380
+
381
+ def _expire_play(learning_conn: sqlite3.Connection, play_id: int) -> bool:
382
+ """Close a play without applying any reward.
383
+
384
+ The posterior is deliberately untouched: nothing was observed, so there is
385
+ nothing to learn, and a neutral update would shrink the arm's variance
386
+ around its prior and make it harder to move once evidence does arrive.
387
+ """
388
+ try:
389
+ learning_conn.execute(
390
+ "UPDATE bandit_plays SET settled_at = ?, settlement_type = ? "
391
+ "WHERE play_id = ? AND settled_at IS NULL",
392
+ (datetime.now(timezone.utc).isoformat(), "unobserved", int(play_id)),
393
+ )
394
+ learning_conn.commit()
395
+ return True
396
+ except sqlite3.Error as exc:
397
+ logger.debug("reward_proxy: expire play %s: %s", play_id, exc)
398
+ return False
399
+
400
+
401
+ def _session_for_play(
402
+ memory_conn: sqlite3.Connection | None,
403
+ query_id: str,
404
+ played_at: datetime,
405
+ profile_id: str,
406
+ ) -> str:
407
+ """The conversation this play happened in, or "" when it cannot be named.
408
+
409
+ The play and its outcome ticket are minted with different uuids on the same
410
+ recall, so the query_id is tried first and a time-and-profile match is the
411
+ fallback. An empty answer means no observation is possible, which the
412
+ caller turns into an abstention rather than a guess.
413
+ """
414
+ if memory_conn is None:
415
+ return ""
416
+ try:
417
+ row = memory_conn.execute(
418
+ "SELECT session_id FROM pending_outcomes WHERE recall_query_id = ? "
419
+ "LIMIT 1", (str(query_id),),
420
+ ).fetchone()
421
+ if row and row[0]:
422
+ return str(row[0])
423
+ window_ms = 5000
424
+ centre = int(played_at.timestamp() * 1000)
425
+ row = memory_conn.execute(
426
+ "SELECT session_id FROM pending_outcomes "
427
+ "WHERE profile_id = ? AND created_at_ms BETWEEN ? AND ? "
428
+ "ORDER BY ABS(created_at_ms - ?) LIMIT 1",
429
+ (str(profile_id), centre - window_ms, centre + window_ms, centre),
430
+ ).fetchone()
431
+ return str(row[0]) if row and row[0] else ""
432
+ except sqlite3.Error:
433
+ return ""
434
+
435
+
436
+ def _ips_for_play(
437
+ learning_conn: sqlite3.Connection, play_id: int, stratum: str, profile_id: str,
438
+ ):
439
+ """Inverse-propensity weight for this play against its stratum's arms."""
440
+ from superlocalmemory.learning.propensity import ips_weight
441
+
442
+ try:
443
+ row = learning_conn.execute(
444
+ "SELECT arm_id FROM bandit_plays WHERE play_id = ?", (int(play_id),),
445
+ ).fetchone()
446
+ if not row or not row[0]:
447
+ return ips_weight(None, None)
448
+ arm_id = str(row[0])
449
+ rows = learning_conn.execute(
450
+ "SELECT arm_id, alpha, beta FROM bandit_arms "
451
+ "WHERE profile_id = ? AND stratum = ?",
452
+ (str(profile_id), str(stratum or "")),
453
+ ).fetchall()
454
+ except sqlite3.Error:
455
+ return ips_weight(None, None)
456
+
457
+ mine, others = None, []
458
+ for candidate, alpha, beta in rows:
459
+ if str(candidate) == arm_id:
460
+ mine = (float(alpha), float(beta))
461
+ else:
462
+ others.append((float(alpha), float(beta)))
463
+ return ips_weight(mine, others)
464
+
465
+
369
466
  def settle_stale_plays(
370
467
  profile_id: str,
371
468
  db_path: Path | str,
@@ -406,27 +503,56 @@ def settle_stale_plays(
406
503
  _shown_fact_ids(learning_conn, row["play_id"])
407
504
  or _top3_fact_ids(learning_conn, row["query_id"])
408
505
  )
409
- reward: float | None = None
410
- kind = "default"
411
- if memory_conn is not None and _tool_event_hit(
412
- memory_conn, played, top3, profile_id=str(profile_id),
413
- ):
414
- reward = 1.0
415
- kind = "proxy_position"
416
- elif memory_conn is not None and _requery_detected(
417
- memory_conn, played, row["query_id"], profile_id=str(profile_id),
418
- ):
419
- reward = 0.0
420
- kind = "proxy_requery"
421
- elif age > _default_deadline(learning_conn, row):
422
- # P1: uncertain default once the window closes.
423
- reward = 0.5
424
- kind = "default"
506
+ # The ladder this replaces asked one question — did a recalled
507
+ # fact_id appear verbatim in a later tool event — and answered 0.5
508
+ # whenever it could not tell. Both halves failed: nothing makes an
509
+ # agent echo the marker, and 0.5 is not an absence of judgement but
510
+ # a confident one, tightening the posterior around its prior.
511
+ requeried = bool(
512
+ memory_conn is not None and _requery_detected(
513
+ memory_conn, played, row["query_id"],
514
+ profile_id=str(profile_id),
515
+ )
516
+ )
517
+ marker_hit = bool(
518
+ memory_conn is not None and _tool_event_hit(
519
+ memory_conn, played, top3, profile_id=str(profile_id),
520
+ )
521
+ )
522
+ session_id = _session_for_play(
523
+ memory_conn, str(row["query_id"] or ""), played, str(profile_id),
524
+ )
525
+ if memory_conn is not None and session_id:
526
+ features = extract_features(
527
+ memory_conn,
528
+ session_id=session_id,
529
+ profile_id=str(profile_id),
530
+ fact_ids=top3,
531
+ recalled_at=played,
532
+ requeried=requeried,
533
+ marker_hit=marker_hit,
534
+ )
425
535
  else:
426
- # Between 60 and 120 s with no evidence yet — wait.
536
+ features = EngagementFeatures(
537
+ requeried=requeried, marker_hit=marker_hit,
538
+ )
539
+
540
+ decision = score(features)
541
+ if decision.reward is None:
542
+ # Nothing observed. Wait while the window is still open, then
543
+ # close the play unjudged rather than inventing a number.
544
+ if age > _ABSTAIN_EXPIRY_SEC:
545
+ _expire_play(learning_conn, int(row["play_id"]))
427
546
  continue
428
547
 
429
- if bandit.update(int(row["play_id"]), reward, kind=kind):
548
+ estimate = _ips_for_play(
549
+ learning_conn, int(row["play_id"]),
550
+ str(row["stratum"] or ""), str(profile_id),
551
+ )
552
+ if bandit.update(
553
+ int(row["play_id"]), decision.reward,
554
+ kind=decision.kind, weight=estimate.weight,
555
+ ):
430
556
  settled += 1
431
557
  finally:
432
558
  try:
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  import os
13
13
  from fastapi import APIRouter, HTTPException, Request
14
14
  from fastapi.responses import JSONResponse
15
+ from superlocalmemory.core.session_identity import synthetic_session_id
15
16
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
16
17
  from superlocalmemory.core.status_contract import (
17
18
  COUNT_QUERIES,
@@ -36,15 +37,15 @@ def _signal_session_id() -> str:
36
37
 
37
38
  agent = str(get_current_agent_id() or "").strip()
38
39
  if agent:
39
- return f"agent:{agent}"
40
+ return synthetic_session_id("agent", agent)
40
41
  except Exception: # noqa: BLE001 -- naming the caller must never fail a read
41
42
  pass
42
43
  try:
43
44
  from superlocalmemory.server.routes.helpers import get_active_profile
44
45
 
45
- return f"api:{get_active_profile()}"
46
+ return synthetic_session_id("api", str(get_active_profile()))
46
47
  except Exception: # noqa: BLE001
47
- return "api:default"
48
+ return synthetic_session_id("api", "default")
48
49
 
49
50
 
50
51
  router = APIRouter(prefix="/api/v3", tags=["v3"])
@@ -205,3 +205,25 @@ def verify(conn: sqlite3.Connection) -> bool:
205
205
  )
206
206
  return False
207
207
  return True
208
+
209
+
210
+ def blocks_serving(conn: sqlite3.Connection) -> bool:
211
+ """Should a daemon refuse to serve while this check does not hold? No.
212
+
213
+ Nothing here is about schema. ``verify()`` reads ``content`` and compares
214
+ today's reading of it against the ``fact_type`` already stored, so a False
215
+ means some memories are filed as plans that no longer read as plans. Every
216
+ table and column a query needs is present either way; the store answers
217
+ normally, and at worst a handful of memories carry a stale label until the
218
+ next maintenance pass re-reads them.
219
+
220
+ The distinction matters because this is a standing guard over data that
221
+ ordinary use re-violates by design: a plan whose date passes stops reading
222
+ as upcoming, which is the rule working, not a fault. Treating that like a
223
+ missing table let one drifted row answer 503 on every route for as long as
224
+ the process lived — and because the readiness snapshot is taken once at
225
+ startup, the background pass that repairs the data could not lift the
226
+ refusal it caused. An outage produced by a quality check is worse than the
227
+ thing the check is for. Same reasoning as ``M043.blocks_serving``.
228
+ """
229
+ return False