superlocalmemory 4.0.6 → 4.0.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.
- package/CHANGELOG.md +128 -4
- package/README.md +6 -11
- 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/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +14 -0
- package/src/superlocalmemory/cli/main.py +9 -0
- package/src/superlocalmemory/cli/summary_cmd.py +215 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +26 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +14 -3
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +212 -0
- package/src/superlocalmemory/code_graph/config.py +65 -1
- package/src/superlocalmemory/core/consolidation_engine.py +14 -15
- package/src/superlocalmemory/core/fact_consolidator.py +24 -1
- package/src/superlocalmemory/core/maintenance.py +51 -1
- package/src/superlocalmemory/core/recall_worker.py +4 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +16 -1
- package/src/superlocalmemory/hooks/hook_handlers.py +38 -11
- package/src/superlocalmemory/learning/pattern_miner.py +12 -7
- package/src/superlocalmemory/mcp/profiles.py +10 -3
- package/src/superlocalmemory/mcp/server.py +7 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +47 -3
- package/src/superlocalmemory/mcp/tools_summaries.py +147 -0
- package/src/superlocalmemory/server/consolidation_runner.py +140 -0
- package/src/superlocalmemory/server/recall_serializer.py +34 -2
- package/src/superlocalmemory/server/routes/agents.py +52 -8
- package/src/superlocalmemory/server/routes/brain.py +108 -0
- package/src/superlocalmemory/server/routes/memories.py +214 -0
- package/src/superlocalmemory/server/routes/v3_api.py +24 -46
- package/src/superlocalmemory/server/unified_daemon.py +107 -0
- package/src/superlocalmemory/storage/schema_code_graph.py +44 -1
- package/src/superlocalmemory/summaries/base.py +159 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +55 -8
- package/src/superlocalmemory/summaries/project_work_log.py +23 -7
- package/src/superlocalmemory/summaries/session_summary.py +9 -5
- package/src/superlocalmemory/ui/index.html +10 -4
- package/src/superlocalmemory/ui/js/fact-detail.js +61 -0
- package/src/superlocalmemory/ui/js/od-boundedloops.js +324 -0
- package/src/superlocalmemory/ui/js/od-memories.js +337 -12
- package/src/superlocalmemory/ui/js/od-mesh.js +97 -5
- package/src/superlocalmemory/ui/js/od-operations.js +1 -150
- package/src/superlocalmemory/ui/js/od-optimize.js +36 -9
- package/src/superlocalmemory/ui/js/od-shell.js +10 -0
|
@@ -170,6 +170,7 @@ def serialize_recall_response(
|
|
|
170
170
|
total_max: int = 12000,
|
|
171
171
|
full: bool = False,
|
|
172
172
|
include_source: bool = False,
|
|
173
|
+
include_marker: bool = False,
|
|
173
174
|
) -> tuple[list[dict], bool]:
|
|
174
175
|
"""Convert a RecallResponse into budgeted, source-disciplined dicts.
|
|
175
176
|
|
|
@@ -185,10 +186,33 @@ def serialize_recall_response(
|
|
|
185
186
|
total_max: Total content char budget before stubs (config-driven).
|
|
186
187
|
full: Bypass clamping/stubs (additive escape hatch).
|
|
187
188
|
include_source: Return full source_content (else ≤280-char preview).
|
|
189
|
+
include_marker: Emit each result's HMAC usage marker. See below.
|
|
188
190
|
|
|
189
191
|
Returns:
|
|
190
192
|
(results, no_confident_match) — results is a list of dicts; the bool
|
|
191
193
|
is the evidence-floor signal lifted from the response (additive).
|
|
194
|
+
|
|
195
|
+
THE MARKER, AND WHY IT WAS MISSING
|
|
196
|
+
----------------------------------
|
|
197
|
+
``run_recall`` sets ``result.marker`` on every result — an HMAC of the
|
|
198
|
+
fact id, computed on the hot path already. Until 4.0.8 **no serialiser
|
|
199
|
+
ever read it**, so the value was computed and discarded on every recall.
|
|
200
|
+
|
|
201
|
+
That one omission broke the entire closed learning loop. The
|
|
202
|
+
``post_tool_outcome`` hook settles an outcome by finding a validated
|
|
203
|
+
``slm:fact:<id>:<hmac8>`` marker in a later tool response; with markers
|
|
204
|
+
never reaching the agent it found nothing, every outcome settled at the
|
|
205
|
+
formula's 0.5 base, and the consequences were visible all the way out to
|
|
206
|
+
the dashboard: 162 outcomes at the default label, all 294 source-quality
|
|
207
|
+
observations at exactly 0.5, therefore ``alpha == beta`` for all 18
|
|
208
|
+
sources and "no quality signal has settled", and 165 bandit arms with 4
|
|
209
|
+
plays between them.
|
|
210
|
+
|
|
211
|
+
Off by default, and gated by the caller on ``session_id``. A marker costs
|
|
212
|
+
roughly 33 characters of the agent's context per result, and it can only
|
|
213
|
+
buy a signal when a ``pending_outcomes`` row exists to settle — which
|
|
214
|
+
happens only for session-bearing recalls. Spending context on an ad-hoc
|
|
215
|
+
recall that could never learn from it is pure waste.
|
|
192
216
|
"""
|
|
193
217
|
memory_map = memory_map or {}
|
|
194
218
|
# T-inject: one shared "now" so every result's age label is consistent.
|
|
@@ -200,7 +224,7 @@ def serialize_recall_response(
|
|
|
200
224
|
_created = getattr(fact, "created_at", "") or ""
|
|
201
225
|
fact_type = getattr(fact, "fact_type", None)
|
|
202
226
|
lifecycle = getattr(fact, "lifecycle", None)
|
|
203
|
-
|
|
227
|
+
entry = {
|
|
204
228
|
"fact_id": fact.fact_id,
|
|
205
229
|
"memory_id": fact.memory_id,
|
|
206
230
|
"content": fact.content or "",
|
|
@@ -235,7 +259,15 @@ def serialize_recall_response(
|
|
|
235
259
|
# weigh recency without doing date math. "" when undated.
|
|
236
260
|
"age_label": relative_age(_created, _now),
|
|
237
261
|
"evidence_chain": list(getattr(r, "evidence_chain", []) or []),
|
|
238
|
-
}
|
|
262
|
+
}
|
|
263
|
+
# Only when asked, and only when the engine actually produced one —
|
|
264
|
+
# an empty key would be indistinguishable from a marker that failed
|
|
265
|
+
# to compute, and the hook validates before trusting anything anyway.
|
|
266
|
+
if include_marker:
|
|
267
|
+
marker = getattr(r, "marker", "") or ""
|
|
268
|
+
if marker:
|
|
269
|
+
entry["marker"] = marker
|
|
270
|
+
raw.append(entry)
|
|
239
271
|
|
|
240
272
|
# F-3 source discipline, then F-2 budget — order matters (discipline first
|
|
241
273
|
# so the template firewall runs before any preview slicing).
|
|
@@ -106,24 +106,68 @@ async def get_agent_memory_activity(
|
|
|
106
106
|
conn = get_read_connection(DB_PATH)
|
|
107
107
|
try:
|
|
108
108
|
try:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
# Group by the agent's OWN identity, not by the capability that
|
|
110
|
+
# authorised the write.
|
|
111
|
+
#
|
|
112
|
+
# This grouped by ``trusted_actor_id``, which on a real store is
|
|
113
|
+
# a capability digest — 43 distinct agents rendered as 43 rows of
|
|
114
|
+
# ``daemon-capability:923b7d6e616f46d3...`` and not one readable
|
|
115
|
+
# name. For a pane whose entire purpose is telling agents apart,
|
|
116
|
+
# that is the same as showing nothing.
|
|
117
|
+
#
|
|
118
|
+
# The real name was already being stored the whole time: writers
|
|
119
|
+
# pass ``agent_id`` and it lands in ``raw_metadata_json``. On this
|
|
120
|
+
# store that yields claude-desktop, claude, gemini, codex, grok
|
|
121
|
+
# and mcp_client. Capability digests remain available per row for
|
|
122
|
+
# audit — they answer "what was allowed to write this", which is a
|
|
123
|
+
# different and also useful question, just not this pane's.
|
|
124
|
+
# raw_metadata_json is absent on stores predating it. Try the
|
|
125
|
+
# identity query and fall back to capability grouping if the
|
|
126
|
+
# column is missing — without this, the OperationalError is
|
|
127
|
+
# caught below and an older install shows ZERO agents while
|
|
128
|
+
# having plenty. Deliberately not PRAGMA table_info: this
|
|
129
|
+
# handler is on the dashboard read path, which is gated against
|
|
130
|
+
# anything that parses as DDL.
|
|
131
|
+
_tail = (
|
|
112
132
|
"COUNT(*) AS cnt, MAX(created_at) AS last_active, "
|
|
113
|
-
"GROUP_CONCAT(DISTINCT source_type) AS sources "
|
|
133
|
+
"GROUP_CONCAT(DISTINCT source_type) AS sources, "
|
|
134
|
+
"COUNT(DISTINCT NULLIF(trusted_actor_id, '')) AS capabilities "
|
|
114
135
|
"FROM ingestion_operations WHERE profile_id=? "
|
|
115
136
|
"GROUP BY agent_id ORDER BY cnt DESC, agent_id ASC "
|
|
116
|
-
"LIMIT 500"
|
|
117
|
-
|
|
118
|
-
|
|
137
|
+
"LIMIT 500"
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
rows = conn.execute(
|
|
141
|
+
"SELECT COALESCE("
|
|
142
|
+
" NULLIF(json_extract(raw_metadata_json, '$.agent_id'), ''),"
|
|
143
|
+
" NULLIF(trusted_actor_id, ''),"
|
|
144
|
+
" 'unknown'"
|
|
145
|
+
") AS agent_id, " + _tail,
|
|
146
|
+
(pid,),
|
|
147
|
+
).fetchall()
|
|
148
|
+
except sqlite3.OperationalError:
|
|
149
|
+
rows = conn.execute(
|
|
150
|
+
"SELECT COALESCE(NULLIF(trusted_actor_id, ''), 'unknown')"
|
|
151
|
+
" AS agent_id, " + _tail,
|
|
152
|
+
(pid,),
|
|
153
|
+
).fetchall()
|
|
119
154
|
for r in rows:
|
|
155
|
+
name = r["agent_id"]
|
|
120
156
|
agents.append({
|
|
121
|
-
"agent_id":
|
|
157
|
+
"agent_id": name,
|
|
122
158
|
"count": r["cnt"],
|
|
123
159
|
"last_active": r["last_active"],
|
|
124
160
|
"source_types": (
|
|
125
161
|
[s for s in (r["sources"] or "").split(",") if s]
|
|
126
162
|
),
|
|
163
|
+
# How many distinct capabilities this agent wrote under.
|
|
164
|
+
"capability_count": r["capabilities"],
|
|
165
|
+
# True when we fell back to a digest — the UI can then say
|
|
166
|
+
# "this writer did not identify itself" instead of
|
|
167
|
+
# presenting a hash as though it were a name.
|
|
168
|
+
"identified": not str(name).startswith(
|
|
169
|
+
("daemon-capability:", "local-capability:")
|
|
170
|
+
) and name != "unknown",
|
|
127
171
|
})
|
|
128
172
|
total += r["cnt"]
|
|
129
173
|
except sqlite3.OperationalError:
|
|
@@ -1764,6 +1764,114 @@ async def patterns_deprecated(
|
|
|
1764
1764
|
}
|
|
1765
1765
|
|
|
1766
1766
|
|
|
1767
|
+
@router.get("/bounded-loops/evidence",
|
|
1768
|
+
dependencies=[Depends(require_install_token)])
|
|
1769
|
+
async def bounded_loops_evidence(
|
|
1770
|
+
request: Request, profile_id: str | None = None, limit: int = 20,
|
|
1771
|
+
) -> dict:
|
|
1772
|
+
"""Terminal Bounded Loops runs this profile has observed.
|
|
1773
|
+
|
|
1774
|
+
Bounded Loops is a SEPARATE PRODUCT. SLM is one optional consumer of a
|
|
1775
|
+
document any MCP client can request over the published contract
|
|
1776
|
+
``bounded-loops.dev/slm-bridge/v1``; neither product depends on the other,
|
|
1777
|
+
and installing either alone is complete.
|
|
1778
|
+
|
|
1779
|
+
What travels is deliberately narrow, and the pane must not imply otherwise:
|
|
1780
|
+
|
|
1781
|
+
* **Observation, not authorization.** ``eligible_for_learning`` is a hard
|
|
1782
|
+
field in the contract and is always ``False`` in v1. A SUCCEEDED run is
|
|
1783
|
+
not permission to retrain, re-rank or route on it. Nothing in SLM treats
|
|
1784
|
+
it as such, and this endpoint returns the flag so the UI can say so.
|
|
1785
|
+
* **Digests, not paths.** ``workspace_id`` is a hash precisely so a client's
|
|
1786
|
+
directory name never reaches a memory system. Gate reasons, artifact
|
|
1787
|
+
contents, commands and environment values are excluded at the source.
|
|
1788
|
+
* **``local_hash_chain_only``.** The receipt log is an append-only hash
|
|
1789
|
+
chain on local disk: tampering is detectable by anyone holding an earlier
|
|
1790
|
+
head. That is NOT authentication, notarization or independent audit, and
|
|
1791
|
+
calling it "verified" would claim a guarantee no part of the system
|
|
1792
|
+
provides.
|
|
1793
|
+
* **``demonstration``** separates real execution from a scripted replay. A
|
|
1794
|
+
demo run proves the wiring works and proves nothing about the work.
|
|
1795
|
+
|
|
1796
|
+
Read-only, off the hot path, and empty is a normal answer — most installs
|
|
1797
|
+
have no Bounded Loops at all.
|
|
1798
|
+
"""
|
|
1799
|
+
profile_id = _authorized_profile(request, profile_id)
|
|
1800
|
+
limit = max(1, min(int(limit or 20), 100))
|
|
1801
|
+
|
|
1802
|
+
out: dict[str, Any] = {
|
|
1803
|
+
"contract": "bounded-loops.dev/slm-bridge/v1",
|
|
1804
|
+
"control_plane": "observation_only",
|
|
1805
|
+
"runs": [],
|
|
1806
|
+
"total": 0,
|
|
1807
|
+
"demonstration_count": 0,
|
|
1808
|
+
"installed": None,
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
try:
|
|
1812
|
+
out["installed"] = bool(_compute_bounded_loops().get("installed"))
|
|
1813
|
+
except Exception: # pragma: no cover — presence probe must never 500
|
|
1814
|
+
out["installed"] = None
|
|
1815
|
+
|
|
1816
|
+
db_path = _learning_db_path()
|
|
1817
|
+
if not db_path.exists():
|
|
1818
|
+
return out
|
|
1819
|
+
|
|
1820
|
+
import sqlite3
|
|
1821
|
+
|
|
1822
|
+
try:
|
|
1823
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=3)
|
|
1824
|
+
conn.row_factory = sqlite3.Row
|
|
1825
|
+
try:
|
|
1826
|
+
rows = conn.execute(
|
|
1827
|
+
"SELECT run_id, run_ref, outcome, run_state, demonstration,"
|
|
1828
|
+
" eligible_for_learning, terminal_at, observed_at,"
|
|
1829
|
+
" receipt_sequence, receipt_trust, workspace_id, contract_id"
|
|
1830
|
+
" FROM external_evidence_receipts WHERE profile_id=?"
|
|
1831
|
+
" ORDER BY observed_at DESC LIMIT ?",
|
|
1832
|
+
(profile_id, limit),
|
|
1833
|
+
).fetchall()
|
|
1834
|
+
total = conn.execute(
|
|
1835
|
+
"SELECT COUNT(*) AS n FROM external_evidence_receipts"
|
|
1836
|
+
" WHERE profile_id=?", (profile_id,),
|
|
1837
|
+
).fetchone()["n"]
|
|
1838
|
+
demos = conn.execute(
|
|
1839
|
+
"SELECT COUNT(*) AS n FROM external_evidence_receipts"
|
|
1840
|
+
" WHERE profile_id=? AND demonstration=1", (profile_id,),
|
|
1841
|
+
).fetchone()["n"]
|
|
1842
|
+
finally:
|
|
1843
|
+
conn.close()
|
|
1844
|
+
except sqlite3.Error as exc:
|
|
1845
|
+
# The table only exists once the bridge has been used. Absent is a
|
|
1846
|
+
# normal state, not an error, and must not surface as a failed pane.
|
|
1847
|
+
logger.debug("bounded-loops evidence unavailable: %s", exc)
|
|
1848
|
+
return out
|
|
1849
|
+
|
|
1850
|
+
out["total"] = total
|
|
1851
|
+
out["demonstration_count"] = demos
|
|
1852
|
+
out["runs"] = [
|
|
1853
|
+
{
|
|
1854
|
+
"run_id": r["run_id"],
|
|
1855
|
+
"run_ref": r["run_ref"],
|
|
1856
|
+
"outcome": r["outcome"],
|
|
1857
|
+
# Both, because the mapping to three buckets loses information: a
|
|
1858
|
+
# HALTED run (budget/policy stop) and a FAILED run (work the gate
|
|
1859
|
+
# rejected) are different events.
|
|
1860
|
+
"run_state": r["run_state"],
|
|
1861
|
+
"demonstration": bool(r["demonstration"]),
|
|
1862
|
+
"eligible_for_learning": bool(r["eligible_for_learning"]),
|
|
1863
|
+
"terminal_at": r["terminal_at"],
|
|
1864
|
+
"observed_at": r["observed_at"],
|
|
1865
|
+
"receipt_sequence": r["receipt_sequence"],
|
|
1866
|
+
"trust": r["receipt_trust"],
|
|
1867
|
+
"workspace_id": r["workspace_id"],
|
|
1868
|
+
"contract": r["contract_id"],
|
|
1869
|
+
}
|
|
1870
|
+
for r in rows
|
|
1871
|
+
]
|
|
1872
|
+
return out
|
|
1873
|
+
|
|
1874
|
+
|
|
1767
1875
|
@router.get("/behavioral",
|
|
1768
1876
|
dependencies=[Depends(require_install_token)])
|
|
1769
1877
|
async def behavioral_deprecated(
|
|
@@ -747,6 +747,159 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
747
747
|
end_recall()
|
|
748
748
|
|
|
749
749
|
|
|
750
|
+
@router.get("/api/summary")
|
|
751
|
+
async def get_summary(request: Request, kind: str = "day", target: str = ""):
|
|
752
|
+
"""Readable summary of memories: a day, a project, or one session (#113).
|
|
753
|
+
|
|
754
|
+
The dashboard surface for the summary layer. 4.0.6 shipped the generators
|
|
755
|
+
with no caller, 4.0.7 added the CLI, 4.0.8 adds this and the MCP tool — the
|
|
756
|
+
"no command, tool or endpoint" gap, closed at the third point.
|
|
757
|
+
|
|
758
|
+
Always returns ``coverage`` and ``source_fact_ids``: a summary that hides how
|
|
759
|
+
much it covered is the opaque generic summary issue #113 warned against.
|
|
760
|
+
Reads memory.db directly; never runs during remember or recall.
|
|
761
|
+
"""
|
|
762
|
+
from datetime import date as _date, timedelta as _timedelta
|
|
763
|
+
|
|
764
|
+
kind = (kind or "day").strip().lower()
|
|
765
|
+
if kind not in ("day", "project", "session"):
|
|
766
|
+
raise HTTPException(status_code=400, detail=f"unknown summary kind '{kind}'")
|
|
767
|
+
|
|
768
|
+
profile = get_active_profile()
|
|
769
|
+
from superlocalmemory.infra.data_root import state_path
|
|
770
|
+
db_path = state_path("memory.db")
|
|
771
|
+
if not db_path.exists():
|
|
772
|
+
raise HTTPException(status_code=404, detail="no memory database")
|
|
773
|
+
|
|
774
|
+
# Pass the loaded config so Mode B/C write the summary. Omitting it silently
|
|
775
|
+
# forces the extractive path for every caller regardless of mode.
|
|
776
|
+
try:
|
|
777
|
+
from superlocalmemory.core.config import SLMConfig
|
|
778
|
+
cfg = SLMConfig.load()
|
|
779
|
+
except Exception:
|
|
780
|
+
cfg = None
|
|
781
|
+
|
|
782
|
+
try:
|
|
783
|
+
if kind == "day":
|
|
784
|
+
from superlocalmemory.summaries import generate_daily_reflection
|
|
785
|
+
day = (target or "").strip() or _date.today().isoformat()
|
|
786
|
+
if day == "today":
|
|
787
|
+
day = _date.today().isoformat()
|
|
788
|
+
elif day == "yesterday":
|
|
789
|
+
day = (_date.today() - _timedelta(days=1)).isoformat()
|
|
790
|
+
result = generate_daily_reflection(db_path, day, profile, cfg)
|
|
791
|
+
elif kind == "project":
|
|
792
|
+
if not (target or "").strip():
|
|
793
|
+
raise HTTPException(status_code=400, detail="project requires target")
|
|
794
|
+
from superlocalmemory.summaries import generate_project_work_log
|
|
795
|
+
result = generate_project_work_log(db_path, target.strip(), profile, cfg)
|
|
796
|
+
else:
|
|
797
|
+
if not (target or "").strip():
|
|
798
|
+
raise HTTPException(status_code=400, detail="session requires target")
|
|
799
|
+
from superlocalmemory.summaries import generate_session_summary
|
|
800
|
+
result = generate_session_summary(db_path, target.strip(), profile, cfg)
|
|
801
|
+
except HTTPException:
|
|
802
|
+
raise
|
|
803
|
+
except Exception:
|
|
804
|
+
raise _internal_error("Summary generation error")
|
|
805
|
+
|
|
806
|
+
return {
|
|
807
|
+
"kind": result.kind,
|
|
808
|
+
"profile_id": result.profile_id,
|
|
809
|
+
"summary": result.content,
|
|
810
|
+
"coverage": result.coverage,
|
|
811
|
+
"generated_by": result.generated_by,
|
|
812
|
+
"source_fact_ids": result.source_fact_ids,
|
|
813
|
+
"source_count": len(result.source_fact_ids),
|
|
814
|
+
"metadata": result.metadata,
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@router.get("/api/summary/projects")
|
|
819
|
+
async def get_summary_projects(request: Request):
|
|
820
|
+
"""Projects SuperLocalMemory has actually observed, for the summary picker.
|
|
821
|
+
|
|
822
|
+
WHY THIS EXISTS
|
|
823
|
+
---------------
|
|
824
|
+
SLM is installed globally and the dashboard is a browser tab — it has no
|
|
825
|
+
working directory, so there is no such thing as "this project" from the
|
|
826
|
+
server's point of view. 4.0.8 shipped a "This project" button that sent an
|
|
827
|
+
empty target and produced "project requires target" every time. A button
|
|
828
|
+
that cannot know its own answer is the wrong control; a list of the projects
|
|
829
|
+
we have seen is the right one.
|
|
830
|
+
|
|
831
|
+
Scope comes from ``tool_events.project_path`` — the directory an agent was
|
|
832
|
+
working in when it called SLM. Deliberately NOT ``entity_profiles.
|
|
833
|
+
project_name``, which has exactly one distinct value on a real store (see
|
|
834
|
+
the note at the top of summaries/project_work_log.py).
|
|
835
|
+
|
|
836
|
+
``tool_events`` is a bounded ring buffer, so this lists recently active
|
|
837
|
+
projects rather than every project in history. ``truncated`` says so
|
|
838
|
+
honestly instead of implying the list is exhaustive.
|
|
839
|
+
"""
|
|
840
|
+
profile = get_active_profile()
|
|
841
|
+
try:
|
|
842
|
+
conn = get_db_connection()
|
|
843
|
+
# get_db_connection() hands back a SHARED read connection, and other
|
|
844
|
+
# handlers set row_factory on it. Never index these rows positionally —
|
|
845
|
+
# whichever handler ran last decides whether r[0] is a column or a
|
|
846
|
+
# KeyError. Name the columns and read them by name.
|
|
847
|
+
conn.row_factory = dict_factory
|
|
848
|
+
cursor = conn.cursor()
|
|
849
|
+
cursor.execute(
|
|
850
|
+
"""
|
|
851
|
+
SELECT project_path AS path, COUNT(*) AS events
|
|
852
|
+
FROM tool_events
|
|
853
|
+
WHERE project_path IS NOT NULL AND project_path != ''
|
|
854
|
+
AND profile_id = ?
|
|
855
|
+
GROUP BY project_path
|
|
856
|
+
ORDER BY events DESC
|
|
857
|
+
LIMIT 50
|
|
858
|
+
""",
|
|
859
|
+
(profile,),
|
|
860
|
+
)
|
|
861
|
+
rows = cursor.fetchall()
|
|
862
|
+
total = cursor.execute(
|
|
863
|
+
"SELECT COUNT(*) AS n FROM tool_events"
|
|
864
|
+
).fetchone()["n"]
|
|
865
|
+
except Exception:
|
|
866
|
+
raise _internal_error("Project list error")
|
|
867
|
+
|
|
868
|
+
projects = [
|
|
869
|
+
{
|
|
870
|
+
"path": r["path"],
|
|
871
|
+
"events": r["events"],
|
|
872
|
+
"label": _project_label(r["path"]),
|
|
873
|
+
}
|
|
874
|
+
for r in rows
|
|
875
|
+
]
|
|
876
|
+
return {
|
|
877
|
+
"projects": projects,
|
|
878
|
+
"profile_id": profile,
|
|
879
|
+
# Surfaced so the UI can explain an unexpectedly short list rather than
|
|
880
|
+
# leaving the user to assume their project was never recorded.
|
|
881
|
+
"truncated": total >= _TOOL_EVENT_RING_SIZE,
|
|
882
|
+
"event_rows": total,
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
#: tool_events is capped; at the cap the project list is a recent window, not history.
|
|
887
|
+
_TOOL_EVENT_RING_SIZE = 2000
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _project_label(path: str) -> str:
|
|
891
|
+
"""Short, human label for a project path.
|
|
892
|
+
|
|
893
|
+
Full paths are long and share prefixes ("/Users/x/Documents/official/..."),
|
|
894
|
+
so a dropdown of raw paths is unreadable. Last two segments keep sibling
|
|
895
|
+
projects distinguishable without the noise.
|
|
896
|
+
"""
|
|
897
|
+
parts = [p for p in str(path).replace("\\", "/").split("/") if p]
|
|
898
|
+
if not parts:
|
|
899
|
+
return str(path)
|
|
900
|
+
return "/".join(parts[-2:]) if len(parts) > 1 else parts[-1]
|
|
901
|
+
|
|
902
|
+
|
|
750
903
|
@router.get("/api/clusters")
|
|
751
904
|
async def get_clusters(request: Request):
|
|
752
905
|
"""Get cluster information with member counts and statistics."""
|
|
@@ -1061,6 +1214,7 @@ async def get_fact_detail(request: Request, fact_id: str):
|
|
|
1061
1214
|
)
|
|
1062
1215
|
except Exception:
|
|
1063
1216
|
row["canonical_entities"] = []
|
|
1217
|
+
row["code_links"] = _code_links_for_fact(fact_id)
|
|
1064
1218
|
return row
|
|
1065
1219
|
except HTTPException:
|
|
1066
1220
|
raise
|
|
@@ -1068,6 +1222,66 @@ async def get_fact_detail(request: Request, fact_id: str):
|
|
|
1068
1222
|
raise _internal_error("Fact detail error")
|
|
1069
1223
|
|
|
1070
1224
|
|
|
1225
|
+
def _code_links_for_fact(fact_id: str) -> list[dict]:
|
|
1226
|
+
"""Code entities this fact mentions, from the code graph.
|
|
1227
|
+
|
|
1228
|
+
Fail-open by design: this is a display extra on a detail popup. No code graph
|
|
1229
|
+
built, bridge switched off, or database missing all mean "no section shown",
|
|
1230
|
+
never an error on the fact itself. A user who has never touched the code
|
|
1231
|
+
graph must not see a failure because of a feature they do not use.
|
|
1232
|
+
|
|
1233
|
+
Reads code_graph.db, which is a separate database from memory.db and is never
|
|
1234
|
+
opened by the recall path — so nothing here can affect recall.
|
|
1235
|
+
"""
|
|
1236
|
+
try:
|
|
1237
|
+
from superlocalmemory.code_graph.config import CodeGraphConfig
|
|
1238
|
+
|
|
1239
|
+
cfg = CodeGraphConfig.load()
|
|
1240
|
+
if not (cfg.enabled and cfg.bridge_enabled):
|
|
1241
|
+
return []
|
|
1242
|
+
db_path = cfg.get_db_path()
|
|
1243
|
+
if not db_path.exists():
|
|
1244
|
+
return []
|
|
1245
|
+
|
|
1246
|
+
import sqlite3
|
|
1247
|
+
|
|
1248
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
1249
|
+
try:
|
|
1250
|
+
conn.row_factory = dict_factory
|
|
1251
|
+
rows = conn.execute(
|
|
1252
|
+
"SELECT cml.link_type, cml.confidence, cml.is_stale, "
|
|
1253
|
+
" cml.enriched_description, "
|
|
1254
|
+
" gn.name, gn.qualified_name, gn.kind, gn.file_path "
|
|
1255
|
+
"FROM code_memory_links cml "
|
|
1256
|
+
"LEFT JOIN graph_nodes gn ON gn.node_id = cml.code_node_id "
|
|
1257
|
+
"WHERE cml.slm_fact_id = ? "
|
|
1258
|
+
"ORDER BY cml.confidence DESC, gn.qualified_name",
|
|
1259
|
+
(fact_id,),
|
|
1260
|
+
).fetchall()
|
|
1261
|
+
finally:
|
|
1262
|
+
conn.close()
|
|
1263
|
+
|
|
1264
|
+
return [
|
|
1265
|
+
{
|
|
1266
|
+
"name": r.get("name") or "",
|
|
1267
|
+
"qualified_name": r.get("qualified_name") or "",
|
|
1268
|
+
"kind": r.get("kind") or "",
|
|
1269
|
+
"file_path": r.get("file_path") or "",
|
|
1270
|
+
"link_type": r.get("link_type") or "mentions",
|
|
1271
|
+
"confidence": r.get("confidence"),
|
|
1272
|
+
"is_stale": bool(r.get("is_stale")),
|
|
1273
|
+
"description": r.get("enriched_description") or "",
|
|
1274
|
+
}
|
|
1275
|
+
for r in rows
|
|
1276
|
+
# A link whose node is gone (LEFT JOIN produced no row) is a stale
|
|
1277
|
+
# pointer, not something to render as a blank entry.
|
|
1278
|
+
if r.get("qualified_name")
|
|
1279
|
+
]
|
|
1280
|
+
except Exception:
|
|
1281
|
+
logger.debug("code links unavailable for %s", fact_id, exc_info=True)
|
|
1282
|
+
return []
|
|
1283
|
+
|
|
1284
|
+
|
|
1071
1285
|
@router.delete("/api/memories/{fact_id}")
|
|
1072
1286
|
async def delete_memory(request: Request, fact_id: str):
|
|
1073
1287
|
"""Delete a specific memory (atomic fact) by ID."""
|
|
@@ -1932,55 +1932,33 @@ async def trigger_consolidation(request: Request):
|
|
|
1932
1932
|
profile_id=pid,
|
|
1933
1933
|
)
|
|
1934
1934
|
|
|
1935
|
-
#
|
|
1936
|
-
#
|
|
1937
|
-
#
|
|
1938
|
-
#
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
#
|
|
1943
|
-
# v3.4.64: ConsolidationEngine.consolidate() is CPU/IO bound (seconds
|
|
1944
|
-
# to minutes). Calling it directly in an async route blocks the ASGI
|
|
1945
|
-
# event loop. Moved into asyncio.to_thread() so the event loop stays
|
|
1946
|
-
# live. The runtime.operation() lease is acquired INSIDE the thread —
|
|
1947
|
-
# blocking a thread is fine; blocking the event loop is not.
|
|
1948
|
-
import asyncio as _asyncio
|
|
1949
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1950
|
-
from superlocalmemory.storage.database import DatabaseManager
|
|
1951
|
-
from superlocalmemory.storage import schema as _schema
|
|
1952
|
-
from superlocalmemory.core.consolidation_engine import ConsolidationEngine
|
|
1953
|
-
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
1954
|
-
|
|
1955
|
-
_app_state = request.app.state
|
|
1935
|
+
# 4.0.8: the body of this handler moved to server/consolidation_runner
|
|
1936
|
+
# so the periodic daemon trigger and this endpoint run the SAME code
|
|
1937
|
+
# under the SAME lock. Two copies would be two definitions of
|
|
1938
|
+
# "consolidated", and only one of them would get maintained.
|
|
1939
|
+
from superlocalmemory.server.consolidation_runner import (
|
|
1940
|
+
run_full_consolidation,
|
|
1941
|
+
)
|
|
1956
1942
|
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1943
|
+
# background=true returns as soon as the pass is scheduled. The
|
|
1944
|
+
# session-end hook needs this: a full consolidation runs for seconds to
|
|
1945
|
+
# minutes, and a hook that waits for it either blocks the user's shell
|
|
1946
|
+
# or times out and wrongly concludes the run failed.
|
|
1947
|
+
if body.get("background"):
|
|
1948
|
+
import asyncio as _asyncio
|
|
1949
|
+
|
|
1950
|
+
_app_state = request.app.state
|
|
1951
|
+
_asyncio.create_task(
|
|
1952
|
+
run_full_consolidation(
|
|
1953
|
+
_app_state, pid, lightweight=lightweight, trigger="hook",
|
|
1965
1954
|
)
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
from superlocalmemory.learning.consolidation_worker import (
|
|
1970
|
-
ConsolidationWorker,
|
|
1971
|
-
)
|
|
1972
|
-
learning_db = config.base_dir / "learning.db"
|
|
1973
|
-
cw = ConsolidationWorker(str(config.db_path), str(learning_db))
|
|
1974
|
-
pattern_count = cw._generate_patterns(pid, False)
|
|
1975
|
-
res["patterns_mined"] = pattern_count
|
|
1976
|
-
logger.info(
|
|
1977
|
-
"Auto-mined %d patterns after consolidation", pattern_count
|
|
1978
|
-
)
|
|
1979
|
-
except Exception as exc:
|
|
1980
|
-
logger.debug("Pattern mining after consolidation failed: %s", exc)
|
|
1981
|
-
return res
|
|
1955
|
+
)
|
|
1956
|
+
authorization.complete()
|
|
1957
|
+
return {"success": True, "started": True, "background": True}
|
|
1982
1958
|
|
|
1983
|
-
result = await
|
|
1959
|
+
result = await run_full_consolidation(
|
|
1960
|
+
request.app.state, pid, lightweight=lightweight, trigger="http",
|
|
1961
|
+
)
|
|
1984
1962
|
authorization.complete()
|
|
1985
1963
|
return {"success": True, **result}
|
|
1986
1964
|
except HTTPException:
|