superlocalmemory 4.0.10 → 4.1.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +170 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- 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 +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- 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-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +263 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +186 -5
- package/src/superlocalmemory/storage/_migration_internals.py +31 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +12 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -330,3 +330,181 @@ def evolution_lineage(request: Request, skill_name: str = ""):
|
|
|
330
330
|
finally:
|
|
331
331
|
if conn is not None:
|
|
332
332
|
conn.close()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
# Approving a quarantined skill
|
|
337
|
+
#
|
|
338
|
+
# A mutation that passes blind verification stops at VERIFIED_QUARANTINED and
|
|
339
|
+
# waits. Nothing moved it from there: auto-approval is off by default, correctly
|
|
340
|
+
# — this system rewrites the instructions an AI follows, and doing that without
|
|
341
|
+
# a person saying yes is not a default anyone should ship. But there was no way
|
|
342
|
+
# for the person to say yes either, so every verified improvement sat in a
|
|
343
|
+
# quarantine directory permanently.
|
|
344
|
+
#
|
|
345
|
+
# This is that path. It does NOT change the default: approval remains explicit,
|
|
346
|
+
# per-candidate, and recorded in the transition chain with who did it.
|
|
347
|
+
# ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class ApproveSkillRequest(BaseModel):
|
|
351
|
+
"""Which candidate to approve. Named by record id, not by skill name.
|
|
352
|
+
|
|
353
|
+
A skill can have several candidates over time and only one of them is the
|
|
354
|
+
one being looked at. Approving "the latest candidate for skill X" would make
|
|
355
|
+
the outcome depend on when the call happened to arrive.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
record_id: str
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
_APPROVABLE = ("verified_quarantined", "promoted")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@router.post("/api/evolution/approve")
|
|
365
|
+
def evolution_approve(request: Request, body: ApproveSkillRequest):
|
|
366
|
+
"""Activate a quarantined skill mutation after human approval."""
|
|
367
|
+
_require_manage(request)
|
|
368
|
+
try:
|
|
369
|
+
from superlocalmemory.evolution.evolution_store import EvolutionStore
|
|
370
|
+
from superlocalmemory.evolution.skill_activator import SkillActivator
|
|
371
|
+
from superlocalmemory.evolution.types import EvolutionStatus
|
|
372
|
+
|
|
373
|
+
profile_id = get_active_profile()
|
|
374
|
+
store = EvolutionStore(str(MEMORY_DIR / "memory.db"))
|
|
375
|
+
|
|
376
|
+
record = store.get_record(body.record_id, profile_id)
|
|
377
|
+
if record is None:
|
|
378
|
+
return {"success": False, "error": "No such evolution candidate"}
|
|
379
|
+
|
|
380
|
+
# Prefer the transition chain over the record's own column: the chain is
|
|
381
|
+
# the audited history and the column is a cache of its last entry.
|
|
382
|
+
latest = store.get_latest_status(body.record_id, profile_id)
|
|
383
|
+
current = (latest.value if latest is not None
|
|
384
|
+
else getattr(record.status, "value", ""))
|
|
385
|
+
|
|
386
|
+
if current not in _APPROVABLE:
|
|
387
|
+
# Refusing an already-active candidate matters: activating twice
|
|
388
|
+
# would overwrite the backup taken the first time with the mutation
|
|
389
|
+
# itself, and the rollback target would become the thing being
|
|
390
|
+
# rolled back.
|
|
391
|
+
return {
|
|
392
|
+
"success": False,
|
|
393
|
+
"error": (
|
|
394
|
+
f"Candidate is {current!r}; only a verified candidate "
|
|
395
|
+
"awaiting approval can be activated"
|
|
396
|
+
),
|
|
397
|
+
"status": current,
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if not record.quarantine_dir_name:
|
|
401
|
+
return {
|
|
402
|
+
"success": False,
|
|
403
|
+
"error": "Candidate has no quarantined artifact to activate",
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
actor = "dashboard"
|
|
407
|
+
|
|
408
|
+
# Approval is recorded BEFORE the file moves, activation after. The
|
|
409
|
+
# order matters in both directions:
|
|
410
|
+
#
|
|
411
|
+
# * recording both afterwards means a successful activation whose log
|
|
412
|
+
# write fails leaves the skill live and the record saying it is not —
|
|
413
|
+
# so the next approval attempt is allowed, and activating twice
|
|
414
|
+
# overwrites the backup that rollback restores from.
|
|
415
|
+
# * recording both beforehand would claim a mutation is live when the
|
|
416
|
+
# file move failed.
|
|
417
|
+
#
|
|
418
|
+
# Approved-then-nothing is a state a person can act on. Live-but-unknown
|
|
419
|
+
# is not.
|
|
420
|
+
store.append_transition(
|
|
421
|
+
body.record_id, profile_id,
|
|
422
|
+
EvolutionStatus(current), EvolutionStatus.APPROVED,
|
|
423
|
+
actor_id=actor, reason="approved by request",
|
|
424
|
+
)
|
|
425
|
+
try:
|
|
426
|
+
activation = SkillActivator().activate(
|
|
427
|
+
record.skill_name, record.quarantine_dir_name, actor_id=actor,
|
|
428
|
+
)
|
|
429
|
+
except Exception as exc:
|
|
430
|
+
# The approval stands and the mutation did not go live. Recorded as
|
|
431
|
+
# such rather than left dangling.
|
|
432
|
+
store.append_transition(
|
|
433
|
+
body.record_id, profile_id,
|
|
434
|
+
EvolutionStatus.APPROVED, EvolutionStatus.REJECTED,
|
|
435
|
+
actor_id=actor, reason=f"activation failed: {exc}",
|
|
436
|
+
)
|
|
437
|
+
raise
|
|
438
|
+
store.append_transition(
|
|
439
|
+
body.record_id, profile_id,
|
|
440
|
+
EvolutionStatus.APPROVED, EvolutionStatus.ACTIVE,
|
|
441
|
+
actor_id=actor, reason="activated from quarantine",
|
|
442
|
+
metadata={"content_hash": activation.get("content_hash", "")},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
"success": True,
|
|
447
|
+
"record_id": body.record_id,
|
|
448
|
+
"skill_name": record.skill_name,
|
|
449
|
+
"status": EvolutionStatus.ACTIVE.value,
|
|
450
|
+
"live_path": activation.get("live_path"),
|
|
451
|
+
"backup_path": activation.get("backup_path"),
|
|
452
|
+
"content_hash": activation.get("content_hash"),
|
|
453
|
+
# So the caller knows how to undo it without reading the source.
|
|
454
|
+
"rollback": "POST /api/evolution/rollback",
|
|
455
|
+
}
|
|
456
|
+
except FileNotFoundError as exc:
|
|
457
|
+
return {"success": False, "error": f"Quarantined artifact missing: {exc}"}
|
|
458
|
+
except Exception:
|
|
459
|
+
logger.exception("evolution_approve error")
|
|
460
|
+
return {"success": False, "error": "Internal server error"}
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class RollbackSkillRequest(BaseModel):
|
|
464
|
+
skill_name: str
|
|
465
|
+
#: Optional, so the reversal can be recorded against the candidate it
|
|
466
|
+
#: reverses. Without it the file is restored and the log still says active,
|
|
467
|
+
#: which reads as "this mutation is live" forever.
|
|
468
|
+
record_id: str = ""
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@router.post("/api/evolution/rollback")
|
|
472
|
+
def evolution_rollback(request: Request, body: RollbackSkillRequest):
|
|
473
|
+
"""Restore a skill's previous instructions after an approval goes wrong.
|
|
474
|
+
|
|
475
|
+
Approval is reversible, and it has to be: the reason a person is in this
|
|
476
|
+
loop is that a verified mutation can still be a bad one, and finding that
|
|
477
|
+
out happens after it is live.
|
|
478
|
+
"""
|
|
479
|
+
_require_manage(request)
|
|
480
|
+
try:
|
|
481
|
+
from superlocalmemory.evolution.evolution_store import EvolutionStore
|
|
482
|
+
from superlocalmemory.evolution.skill_activator import SkillActivator
|
|
483
|
+
from superlocalmemory.evolution.types import EvolutionStatus
|
|
484
|
+
|
|
485
|
+
result = SkillActivator().rollback(body.skill_name)
|
|
486
|
+
|
|
487
|
+
# Record the reversal. A restored file with the log still reading
|
|
488
|
+
# "active" says the mutation is live when it is not, and that is the
|
|
489
|
+
# record an audit would read.
|
|
490
|
+
if body.record_id:
|
|
491
|
+
try:
|
|
492
|
+
store = EvolutionStore(str(MEMORY_DIR / "memory.db"))
|
|
493
|
+
pid = get_active_profile()
|
|
494
|
+
latest = store.get_latest_status(body.record_id, pid)
|
|
495
|
+
store.append_transition(
|
|
496
|
+
body.record_id, pid,
|
|
497
|
+
latest or EvolutionStatus.ACTIVE,
|
|
498
|
+
EvolutionStatus.ROLLED_BACK,
|
|
499
|
+
actor_id="dashboard", reason="rolled back by request",
|
|
500
|
+
)
|
|
501
|
+
except Exception as exc: # pragma: no cover — the file is restored
|
|
502
|
+
logger.warning("rollback recorded no transition: %s", exc)
|
|
503
|
+
result["transition_recorded"] = False
|
|
504
|
+
|
|
505
|
+
return {"success": True, **result}
|
|
506
|
+
except FileNotFoundError as exc:
|
|
507
|
+
return {"success": False, "error": f"No backup to restore: {exc}"}
|
|
508
|
+
except Exception:
|
|
509
|
+
logger.exception("evolution_rollback error")
|
|
510
|
+
return {"success": False, "error": "Internal server error"}
|
|
@@ -81,6 +81,14 @@ async def ingest(req: IngestRequest, request: Request):
|
|
|
81
81
|
request,
|
|
82
82
|
actor_kind="http-ingest",
|
|
83
83
|
)
|
|
84
|
+
# Machine authentication says which process is calling. It does not say
|
|
85
|
+
# whether that caller's user may write to this workspace. Every other
|
|
86
|
+
# write route asks that question; this one did not, so a viewer could
|
|
87
|
+
# write through it in a workspace that requires a login.
|
|
88
|
+
from superlocalmemory.access.rbac import Permission
|
|
89
|
+
from superlocalmemory.server.rbac_enforce import require_permission
|
|
90
|
+
|
|
91
|
+
require_permission(request, Permission.WRITE, profile=engine._profile_id)
|
|
84
92
|
command = build_engine_ingestion_command(engine)
|
|
85
93
|
receipt, created = command.submit_with_status(IngestionRequest(
|
|
86
94
|
content=req.content,
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
import logging
|
|
8
8
|
import sqlite3
|
|
9
9
|
from pathlib import Path
|
|
10
|
+
from superlocalmemory.learning.signal_kinds import FEEDBACK_ONLY_SQL
|
|
10
11
|
|
|
11
12
|
logger = logging.getLogger("superlocalmemory.routes.learning")
|
|
12
13
|
|
|
@@ -24,7 +25,7 @@ class ReadOnlyRankerStore:
|
|
|
24
25
|
try:
|
|
25
26
|
row = connection.execute(
|
|
26
27
|
"SELECT COUNT(*) AS count FROM learning_signals "
|
|
27
|
-
"WHERE profile_id = ?",
|
|
28
|
+
f"WHERE profile_id = ?{FEEDBACK_ONLY_SQL}",
|
|
28
29
|
(profile_id,),
|
|
29
30
|
).fetchone()
|
|
30
31
|
return int(row["count"] if row else 0)
|
|
@@ -13,6 +13,11 @@ from typing import Optional
|
|
|
13
13
|
|
|
14
14
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
15
15
|
|
|
16
|
+
from superlocalmemory.core.config import BROWSE_PAGE_SIZE
|
|
17
|
+
from superlocalmemory.storage.database import (
|
|
18
|
+
visible_fact_clause_for_connection,
|
|
19
|
+
)
|
|
20
|
+
|
|
16
21
|
from .helpers import (
|
|
17
22
|
SearchRequest,
|
|
18
23
|
dict_factory,
|
|
@@ -131,10 +136,20 @@ def _admit_http_mutation(request: Request, operation: str) -> None:
|
|
|
131
136
|
resolve_principal,
|
|
132
137
|
)
|
|
133
138
|
|
|
139
|
+
from superlocalmemory.core.admission import _company_mode_active
|
|
140
|
+
|
|
141
|
+
# Both switches, the same rule the other entry points use. Reading
|
|
142
|
+
# config.toml alone left this path treating a workspace as personal after
|
|
143
|
+
# the dashboard toggle had turned per-user access on -- so a write refused
|
|
144
|
+
# everywhere else was admitted here.
|
|
134
145
|
deployment = getattr(request.app.state, "deployment", None)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
146
|
+
if deployment is None:
|
|
147
|
+
from superlocalmemory.core.admission import _resolve_deployment
|
|
148
|
+
|
|
149
|
+
deployment = _resolve_deployment()
|
|
150
|
+
company = _company_mode_active(deployment)
|
|
151
|
+
tier = "enterprise" if company else "personal"
|
|
152
|
+
mode = "company" if company else "local"
|
|
138
153
|
|
|
139
154
|
principal_info = resolve_principal(request)
|
|
140
155
|
principal = str(principal_info.get("user_id") or "")
|
|
@@ -388,7 +403,7 @@ async def get_memories(
|
|
|
388
403
|
cluster_id: Optional[int] = None,
|
|
389
404
|
min_importance: Optional[int] = None,
|
|
390
405
|
tags: Optional[str] = None,
|
|
391
|
-
limit: int = Query(
|
|
406
|
+
limit: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200),
|
|
392
407
|
offset: int = Query(0, ge=0),
|
|
393
408
|
filter: Optional[str] = Query(
|
|
394
409
|
None,
|
|
@@ -444,15 +459,24 @@ async def get_memories(
|
|
|
444
459
|
scope_where = "profile_id = ?"
|
|
445
460
|
scope_params = [active_profile]
|
|
446
461
|
|
|
462
|
+
# Withheld rows are excluded from BOTH the page and the total. On
|
|
463
|
+
# the author's store this list served 24 model-authored summaries
|
|
464
|
+
# in its first 50 rows and reported 5,218 memories against 3,919
|
|
465
|
+
# real ones -- the count a user reads, inflated by exactly the
|
|
466
|
+
# 1,299 rows 4.0.10 withheld.
|
|
467
|
+
visible = visible_fact_clause_for_connection(conn)
|
|
447
468
|
query = (
|
|
448
469
|
"SELECT fact_id as id, memory_id, content, fact_type as category, "
|
|
449
470
|
"confidence as importance, access_count, "
|
|
450
471
|
"created_at, created_at as updated_at, "
|
|
451
472
|
"session_id as project_name, scope, shared_with "
|
|
452
|
-
f"FROM atomic_facts WHERE {scope_where}"
|
|
473
|
+
f"FROM atomic_facts WHERE {scope_where}{visible}"
|
|
453
474
|
)
|
|
454
475
|
params = list(scope_params)
|
|
455
|
-
count_base =
|
|
476
|
+
count_base = (
|
|
477
|
+
"SELECT COUNT(*) as total FROM atomic_facts "
|
|
478
|
+
f"WHERE {scope_where}{visible}"
|
|
479
|
+
)
|
|
456
480
|
else:
|
|
457
481
|
query = """
|
|
458
482
|
SELECT id, content, summary, category, project_name, project_path,
|
|
@@ -656,6 +680,11 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
656
680
|
lambda: engine.recall(
|
|
657
681
|
body.query, limit=body.limit, fast=True,
|
|
658
682
|
window=_window or None,
|
|
683
|
+
# Name the surface. A recall with no name leaves no record
|
|
684
|
+
# an outcome can be matched to, and a search typed into the
|
|
685
|
+
# dashboard is one continuous thread of use, not a
|
|
686
|
+
# conversation turn.
|
|
687
|
+
session_id=f"dashboard:{get_active_profile()}",
|
|
659
688
|
),
|
|
660
689
|
)
|
|
661
690
|
# A run_in_executor thread cannot be cancelled, and wait_for() on it
|
|
@@ -803,6 +832,13 @@ async def get_summary(request: Request, kind: str = "day", target: str = ""):
|
|
|
803
832
|
except Exception:
|
|
804
833
|
raise _internal_error("Summary generation error")
|
|
805
834
|
|
|
835
|
+
# Say why, not just what. The response already reported that a summary was
|
|
836
|
+
# assembled rather than written; someone seeing a plainer result than they
|
|
837
|
+
# expected still had no way to learn that a written one needs a model, and
|
|
838
|
+
# would reasonably read the feature as broken.
|
|
839
|
+
from superlocalmemory.core.mode_capability import llm_capability
|
|
840
|
+
|
|
841
|
+
generated = str(getattr(result, "generated_by", "") or "")
|
|
806
842
|
return {
|
|
807
843
|
"kind": result.kind,
|
|
808
844
|
"profile_id": result.profile_id,
|
|
@@ -812,6 +848,12 @@ async def get_summary(request: Request, kind: str = "day", target: str = ""):
|
|
|
812
848
|
"source_fact_ids": result.source_fact_ids,
|
|
813
849
|
"source_count": len(result.source_fact_ids),
|
|
814
850
|
"metadata": result.metadata,
|
|
851
|
+
"capability": llm_capability(
|
|
852
|
+
cfg,
|
|
853
|
+
# This call has just been made, so its outcome is the honest answer
|
|
854
|
+
# about availability -- better than asking the configuration again.
|
|
855
|
+
llm_reachable=generated.startswith("llm"),
|
|
856
|
+
),
|
|
815
857
|
}
|
|
816
858
|
|
|
817
859
|
|
|
@@ -992,7 +1034,7 @@ async def get_clusters(request: Request):
|
|
|
992
1034
|
async def get_cluster_detail(
|
|
993
1035
|
request: Request,
|
|
994
1036
|
cluster_id: str,
|
|
995
|
-
limit: int = Query(
|
|
1037
|
+
limit: int = Query(BROWSE_PAGE_SIZE, ge=1, le=200),
|
|
996
1038
|
):
|
|
997
1039
|
"""Get detailed view of a specific cluster (scene)."""
|
|
998
1040
|
try:
|
|
@@ -17,6 +17,9 @@ import sqlite3
|
|
|
17
17
|
from fastapi import APIRouter, Query
|
|
18
18
|
from fastapi.responses import JSONResponse
|
|
19
19
|
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
|
|
20
|
+
from superlocalmemory.storage.database import (
|
|
21
|
+
visible_fact_clause_for_connection,
|
|
22
|
+
)
|
|
20
23
|
|
|
21
24
|
logger = logging.getLogger(__name__)
|
|
22
25
|
|
|
@@ -82,6 +85,7 @@ async def get_timeline(
|
|
|
82
85
|
"confidence, session_id "
|
|
83
86
|
"FROM atomic_facts "
|
|
84
87
|
"WHERE profile_id = ? AND created_at >= ? AND lifecycle = 'active' "
|
|
88
|
+
f"{visible_fact_clause_for_connection(conn)} "
|
|
85
89
|
"ORDER BY created_at DESC LIMIT ?",
|
|
86
90
|
(pid, start_date, INTERNAL_CEILING),
|
|
87
91
|
).fetchall()
|
|
@@ -8,14 +8,45 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
|
+
from pathlib import Path
|
|
11
12
|
import os
|
|
12
13
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
14
|
from fastapi.responses import JSONResponse
|
|
15
|
+
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
16
|
+
from superlocalmemory.core.status_contract import (
|
|
17
|
+
COUNT_QUERIES,
|
|
18
|
+
counts_from_sqlite,
|
|
19
|
+
projection_queue_depth,
|
|
20
|
+
store_size_mb,
|
|
21
|
+
)
|
|
14
22
|
from superlocalmemory.server.routes.helpers import SLM_VERSION, get_read_connection
|
|
15
23
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
16
24
|
|
|
17
25
|
logger = logging.getLogger(__name__)
|
|
18
26
|
|
|
27
|
+
def _signal_session_id() -> str:
|
|
28
|
+
"""A name for the caller, for matching an outcome back to this recall.
|
|
29
|
+
|
|
30
|
+
The agent id the request arrived under, when it arrived under one. Falls
|
|
31
|
+
back to the workspace, which keeps dashboard and scripted traffic separable
|
|
32
|
+
from an agent's. Never empty: an unnamed recall leaves no record.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
36
|
+
|
|
37
|
+
agent = str(get_current_agent_id() or "").strip()
|
|
38
|
+
if agent:
|
|
39
|
+
return f"agent:{agent}"
|
|
40
|
+
except Exception: # noqa: BLE001 -- naming the caller must never fail a read
|
|
41
|
+
pass
|
|
42
|
+
try:
|
|
43
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
44
|
+
|
|
45
|
+
return f"api:{get_active_profile()}"
|
|
46
|
+
except Exception: # noqa: BLE001
|
|
47
|
+
return "api:default"
|
|
48
|
+
|
|
49
|
+
|
|
19
50
|
router = APIRouter(prefix="/api/v3", tags=["v3"])
|
|
20
51
|
|
|
21
52
|
|
|
@@ -102,20 +133,15 @@ async def dashboard(request: Request):
|
|
|
102
133
|
|
|
103
134
|
# Read stats directly from SQLite (dashboard doesn't load engine)
|
|
104
135
|
memory_count = 0
|
|
105
|
-
|
|
136
|
+
counts = dict.fromkeys(COUNT_QUERIES, 0)
|
|
137
|
+
queue_depth = 0
|
|
106
138
|
db_path = config.base_dir / "memory.db"
|
|
107
139
|
if db_path.exists():
|
|
108
140
|
try:
|
|
109
141
|
conn = get_read_connection(db_path)
|
|
142
|
+
counts = counts_from_sqlite(conn, active_profile)
|
|
143
|
+
queue_depth = projection_queue_depth(conn)
|
|
110
144
|
cursor = conn.cursor()
|
|
111
|
-
try:
|
|
112
|
-
cursor.execute(
|
|
113
|
-
"SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
|
|
114
|
-
(active_profile,),
|
|
115
|
-
)
|
|
116
|
-
fact_count = cursor.fetchone()[0]
|
|
117
|
-
except Exception:
|
|
118
|
-
pass
|
|
119
145
|
try:
|
|
120
146
|
try:
|
|
121
147
|
cursor.execute(
|
|
@@ -143,10 +169,23 @@ async def dashboard(request: Request):
|
|
|
143
169
|
"provider": config.llm.provider or "none",
|
|
144
170
|
"model": config.llm.model or "",
|
|
145
171
|
"memory_count": memory_count,
|
|
146
|
-
"fact_count": fact_count,
|
|
147
172
|
"profile": active_profile,
|
|
148
173
|
"base_dir": str(config.base_dir),
|
|
149
174
|
"version": SLM_VERSION,
|
|
175
|
+
# The counts and the store's own address were missing here while
|
|
176
|
+
# every other status surface carried them, so the one surface a
|
|
177
|
+
# person actually looks at could not answer "is the graph healthy".
|
|
178
|
+
"db_path": str(db_path),
|
|
179
|
+
"db_size_mb": store_size_mb(db_path),
|
|
180
|
+
"profile_generation": get_profile_runtime(
|
|
181
|
+
request.app.state,
|
|
182
|
+
).snapshot.generation,
|
|
183
|
+
# Facts stored but not yet in the graph and vector projections. A
|
|
184
|
+
# number that does not fall is a projection that has stopped
|
|
185
|
+
# keeping up, which is otherwise invisible: nothing errors, the
|
|
186
|
+
# memory is safely in SQLite, and recall just quietly gets worse.
|
|
187
|
+
"projection_queue_depth": queue_depth,
|
|
188
|
+
**counts,
|
|
150
189
|
}
|
|
151
190
|
payload.update(dashboard_mode_fields(config.mode))
|
|
152
191
|
return payload
|
|
@@ -495,12 +534,45 @@ async def set_full_config(request: Request):
|
|
|
495
534
|
_emb_fields = ("embedding_provider", "embedding_endpoint", "embedding_key",
|
|
496
535
|
"embedding_model", "embedding_dimension")
|
|
497
536
|
if any(k in body for k in _emb_fields):
|
|
537
|
+
_old_emb = config.embedding
|
|
538
|
+
_new_provider = body.get("embedding_provider", "")
|
|
539
|
+
_new_model = body.get("embedding_model", "")
|
|
540
|
+
_new_dim = int(body.get("embedding_dimension", 0) or 0)
|
|
541
|
+
# The same range the other save route enforces. Without it a
|
|
542
|
+
# dashboard save with no dimension field stored a width of zero.
|
|
543
|
+
if _new_dim and not (64 <= _new_dim <= 8192):
|
|
544
|
+
return JSONResponse(
|
|
545
|
+
{"error": f"Dimension must be 64-8192, got {_new_dim}"},
|
|
546
|
+
status_code=400,
|
|
547
|
+
)
|
|
548
|
+
# The SECOND way to change the embedding model, and it was
|
|
549
|
+
# unguarded. Switching mode from the dashboard carries the embedding
|
|
550
|
+
# fields, so a width that the store cannot hold arrived here
|
|
551
|
+
# untouched while the other route refused it — one door bolted, the
|
|
552
|
+
# other open.
|
|
553
|
+
if not bool(body.get("force")):
|
|
554
|
+
_refusal = _refuse_incompatible_embedding(
|
|
555
|
+
config, config.embedding, _new_model, _new_dim,
|
|
556
|
+
new_provider=_new_provider,
|
|
557
|
+
)
|
|
558
|
+
if _refusal is not None:
|
|
559
|
+
return _refusal
|
|
498
560
|
config.embedding = EmbeddingConfig(
|
|
499
|
-
provider=
|
|
561
|
+
provider=_new_provider,
|
|
500
562
|
api_endpoint=body.get("embedding_endpoint", ""),
|
|
501
563
|
api_key=body.get("embedding_key", ""),
|
|
502
|
-
model_name=
|
|
503
|
-
dimension=
|
|
564
|
+
model_name=_new_model,
|
|
565
|
+
dimension=_new_dim or _old_emb.dimension,
|
|
566
|
+
# Not naming these reset them to defaults, so every embedding
|
|
567
|
+
# save from the dashboard silently put the local model back to
|
|
568
|
+
# whatever ships — the exact defect the other route had.
|
|
569
|
+
ollama_model=(
|
|
570
|
+
_new_model if _new_provider == "ollama" and _new_model
|
|
571
|
+
else _old_emb.ollama_model
|
|
572
|
+
),
|
|
573
|
+
ollama_base_url=_old_emb.ollama_base_url,
|
|
574
|
+
api_version=_old_emb.api_version,
|
|
575
|
+
deployment_name=_old_emb.deployment_name,
|
|
504
576
|
)
|
|
505
577
|
|
|
506
578
|
# When the mode actually changed, apply the new mode's structural presets
|
|
@@ -571,6 +643,85 @@ async def get_embedding_config(request: Request):
|
|
|
571
643
|
return _internal_error()
|
|
572
644
|
|
|
573
645
|
|
|
646
|
+
def _refuse_incompatible_embedding(
|
|
647
|
+
config, old_emb, new_model, new_dim, new_provider=None,
|
|
648
|
+
):
|
|
649
|
+
"""None when the change is safe, otherwise the 409 to return instead.
|
|
650
|
+
|
|
651
|
+
Fail-open on anything it cannot determine: a store with no vectors yet, a
|
|
652
|
+
model server that is not running, an unreadable database. Refusing on
|
|
653
|
+
"I could not tell" would block a legitimate first-time setup, and the
|
|
654
|
+
dimension a caller declares is still checked against the store either way.
|
|
655
|
+
"""
|
|
656
|
+
from fastapi.responses import JSONResponse as _JSON
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
from superlocalmemory.core.ollama_validator import (
|
|
660
|
+
EMBEDDING,
|
|
661
|
+
stored_embedding_dimension,
|
|
662
|
+
validate_ollama_model,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
db_path = Path(config.base_dir) / "memory.db"
|
|
666
|
+
stored = stored_embedding_dimension(db_path)
|
|
667
|
+
if stored is None:
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
# The provider being SAVED, falling back to the current one when the
|
|
671
|
+
# caller is not changing it. Reading only the current provider meant
|
|
672
|
+
# that SWITCHING to a local model never probed at all — and switching
|
|
673
|
+
# is exactly when the width changes.
|
|
674
|
+
effective_provider = (
|
|
675
|
+
new_provider
|
|
676
|
+
if new_provider is not None
|
|
677
|
+
else getattr(old_emb, "provider", "")
|
|
678
|
+
)
|
|
679
|
+
measured = None
|
|
680
|
+
if effective_provider == "ollama" or getattr(old_emb, "provider", "") == "ollama":
|
|
681
|
+
# The model being SAVED, not the one already configured. Probing the
|
|
682
|
+
# old one always matched the stored width and therefore always
|
|
683
|
+
# allowed the change — the guard measured the thing it was not
|
|
684
|
+
# protecting against.
|
|
685
|
+
probe = validate_ollama_model(
|
|
686
|
+
new_model or getattr(old_emb, "ollama_model", ""),
|
|
687
|
+
EMBEDDING,
|
|
688
|
+
base_url=getattr(old_emb, "ollama_base_url", "")
|
|
689
|
+
or "http://localhost:11434",
|
|
690
|
+
)
|
|
691
|
+
measured = probe.dimension if probe.ok else None
|
|
692
|
+
|
|
693
|
+
declared = int(new_dim or 0)
|
|
694
|
+
if measured is None and declared <= 0:
|
|
695
|
+
# Nothing to compare: the server could not be asked and the caller
|
|
696
|
+
# named no width. Allowing is the fail-open the first-time setup
|
|
697
|
+
# needs; the other route's probe still guards the common path.
|
|
698
|
+
return None
|
|
699
|
+
effective = measured if measured is not None else declared
|
|
700
|
+
if effective == stored:
|
|
701
|
+
return None
|
|
702
|
+
|
|
703
|
+
return _JSON(
|
|
704
|
+
{
|
|
705
|
+
"error": "embedding_width_mismatch",
|
|
706
|
+
"stored_dimension": stored,
|
|
707
|
+
"requested_dimension": effective,
|
|
708
|
+
"model_name": new_model,
|
|
709
|
+
"detail": (
|
|
710
|
+
f"{new_model} produces {effective}-dimensional vectors and "
|
|
711
|
+
f"this store holds {stored}-dimensional ones. Vectors of "
|
|
712
|
+
f"different widths cannot be compared, so every memory "
|
|
713
|
+
f"already stored would become unfindable by meaning. "
|
|
714
|
+
f"Rebuild them first with: slm db migrate — or resend with "
|
|
715
|
+
f"force=true if they have already been rebuilt."
|
|
716
|
+
),
|
|
717
|
+
},
|
|
718
|
+
status_code=409,
|
|
719
|
+
)
|
|
720
|
+
except Exception: # noqa: BLE001 - never block a save on the check failing
|
|
721
|
+
logger.exception("embedding width pre-check failed; allowing the save")
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
574
725
|
@router.put("/embedding/config")
|
|
575
726
|
async def set_embedding_config(request: Request):
|
|
576
727
|
"""Update embedding configuration independently of mode switch."""
|
|
@@ -591,13 +742,34 @@ async def set_embedding_config(request: Request):
|
|
|
591
742
|
new_key = body.get("api_key", config.embedding.api_key)
|
|
592
743
|
|
|
593
744
|
old_emb = config.embedding
|
|
745
|
+
|
|
746
|
+
# A width that disagrees with what the store already holds is refused
|
|
747
|
+
# here, at the moment of writing, not merely offered as a check the
|
|
748
|
+
# caller may or may not have run. Vectors of different widths cannot be
|
|
749
|
+
# compared, so the store would keep answering similarity questions and
|
|
750
|
+
# every answer would be noise. ``force=true`` is the escape hatch for
|
|
751
|
+
# somebody who has already re-embedded.
|
|
752
|
+
if not bool(body.get("force")):
|
|
753
|
+
refusal = _refuse_incompatible_embedding(
|
|
754
|
+
config, old_emb, new_model, new_dim, new_provider=new_provider,
|
|
755
|
+
)
|
|
756
|
+
if refusal is not None:
|
|
757
|
+
return refusal
|
|
758
|
+
|
|
594
759
|
config.embedding = EmbeddingConfig(
|
|
595
760
|
model_name=new_model,
|
|
596
761
|
dimension=new_dim,
|
|
597
762
|
provider=new_provider,
|
|
598
763
|
api_endpoint=new_endpoint,
|
|
599
764
|
api_key=new_key,
|
|
600
|
-
|
|
765
|
+
# In Ollama mode the embedder resolves its model from
|
|
766
|
+
# ``ollama_model``, so keeping the old value here made a rename a
|
|
767
|
+
# no-op that still answered "success". A caller naming a model gets
|
|
768
|
+
# that model.
|
|
769
|
+
ollama_model=(
|
|
770
|
+
new_model if new_provider == "ollama" and new_model
|
|
771
|
+
else old_emb.ollama_model
|
|
772
|
+
),
|
|
601
773
|
ollama_base_url=old_emb.ollama_base_url,
|
|
602
774
|
api_version=old_emb.api_version,
|
|
603
775
|
deployment_name=old_emb.deployment_name,
|
|
@@ -1018,7 +1190,7 @@ async def recall_trace(request: Request):
|
|
|
1018
1190
|
try:
|
|
1019
1191
|
body = await request.json()
|
|
1020
1192
|
query = body.get("query", "")
|
|
1021
|
-
limit = body.get("limit",
|
|
1193
|
+
limit = body.get("limit", CANONICAL_RECALL_LIMIT)
|
|
1022
1194
|
window = body.get("window", "") or ""
|
|
1023
1195
|
as_of_raw = (body.get("as_of", "") or "").strip()
|
|
1024
1196
|
raw_known_as_of = body.get("known_as_of", "")
|
|
@@ -1073,6 +1245,10 @@ async def recall_trace(request: Request):
|
|
|
1073
1245
|
window=window or None, as_of=_as_of,
|
|
1074
1246
|
known_as_of=_known_as_of, valid_at=_valid_at,
|
|
1075
1247
|
include_unknown=include_unknown,
|
|
1248
|
+
# Whoever asked, by the name they arrived under. Without a name
|
|
1249
|
+
# the record of this recall is discarded and no outcome
|
|
1250
|
+
# reported afterwards can be matched back to it.
|
|
1251
|
+
session_id=_signal_session_id(),
|
|
1076
1252
|
),
|
|
1077
1253
|
)
|
|
1078
1254
|
elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
@@ -231,14 +231,30 @@ def create_app() -> FastAPI:
|
|
|
231
231
|
"<p><a href='/api/docs'>API Documentation</a></p>"
|
|
232
232
|
"</body></html>"
|
|
233
233
|
)
|
|
234
|
-
from superlocalmemory.server.asset_versions import render_index
|
|
235
234
|
from superlocalmemory import __version__ as _v
|
|
236
235
|
|
|
237
236
|
# __SLM_VERSION__ was substituted only by the unified daemon, so the
|
|
238
237
|
# dashboard's upgrade detector did nothing when served from here.
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
)
|
|
238
|
+
# Asset versioning is cosmetic. It must never be why this page 500s.
|
|
239
|
+
#
|
|
240
|
+
# The import is deferred (house style, keeps startup lean), which means
|
|
241
|
+
# it resolves at REQUEST time — so when `pip install -e .` replaced the
|
|
242
|
+
# installed package underneath a running daemon, this route began
|
|
243
|
+
# answering "Internal Server Error" on the dashboard while every other
|
|
244
|
+
# endpoint was fine. A stale hand-written version string is a trifle; a
|
|
245
|
+
# blank page is not. Fall back to the file as written.
|
|
246
|
+
try:
|
|
247
|
+
from superlocalmemory.server.asset_versions import render_index
|
|
248
|
+
|
|
249
|
+
return render_index(
|
|
250
|
+
index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
|
|
251
|
+
)
|
|
252
|
+
except Exception as exc: # noqa: BLE001 — serve the page regardless
|
|
253
|
+
logger.warning(
|
|
254
|
+
"asset version rewrite unavailable, serving index.html as "
|
|
255
|
+
"written: %s: %s", type(exc).__name__, exc,
|
|
256
|
+
)
|
|
257
|
+
return index_path.read_text().replace("__SLM_VERSION__", _v)
|
|
242
258
|
|
|
243
259
|
@application.get("/favicon.ico", include_in_schema=False)
|
|
244
260
|
async def favicon():
|