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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""``slm summary`` — the readable layer over your memories (issue #113).
|
|
6
|
+
|
|
7
|
+
WHY THIS FILE EXISTS
|
|
8
|
+
--------------------
|
|
9
|
+
4.0.6 shipped the three generators in ``superlocalmemory/summaries/`` with no way
|
|
10
|
+
to call them: no command, no MCP tool, no route. The changelog listed the feature
|
|
11
|
+
as added, the issue reply said it had landed, and a user could do nothing with it.
|
|
12
|
+
This is that missing surface.
|
|
13
|
+
|
|
14
|
+
Three summaries, each bounded and traceable:
|
|
15
|
+
|
|
16
|
+
``slm summary session <id>`` what one session covered
|
|
17
|
+
``slm summary day [DATE]`` what a day's main topics were
|
|
18
|
+
``slm summary project <path>`` what was worked on in a project
|
|
19
|
+
|
|
20
|
+
Every result states its coverage. Session data in particular is sparse — roughly
|
|
21
|
+
4% of facts carry a session id on a real store — so a session summary reports what
|
|
22
|
+
fraction it could actually see rather than presenting a slice as the whole.
|
|
23
|
+
|
|
24
|
+
No language model is required: the generators are extractive by default, so this
|
|
25
|
+
works in Local Guardian mode with nothing installed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from argparse import Namespace
|
|
32
|
+
from datetime import date, timedelta
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from superlocalmemory.infra.data_root import state_path
|
|
37
|
+
|
|
38
|
+
def _coverage_is_complete(coverage: str) -> bool:
|
|
39
|
+
"""Whether *coverage* means "this really is the whole picture".
|
|
40
|
+
|
|
41
|
+
Deliberately inverted. My first version listed the values that needed a
|
|
42
|
+
caveat — ``("partial", "sparse", "none", "empty")`` — and three of those four
|
|
43
|
+
are not values this system emits. The real vocabulary is COVERAGE_FULL /
|
|
44
|
+
PARTIAL / INSUFFICIENT / NO_SESSION / UNAVAILABLE, so a session summary
|
|
45
|
+
reporting "no_session" printed no caveat at all: the one honesty feature
|
|
46
|
+
issue #113 asked for, silently inactive.
|
|
47
|
+
|
|
48
|
+
Testing for completeness instead means any value that is not FULL — including
|
|
49
|
+
one added later — gets the caveat. The failure mode becomes an unnecessary
|
|
50
|
+
warning rather than a missing one.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
from superlocalmemory.summaries.base import COVERAGE_FULL
|
|
54
|
+
|
|
55
|
+
return coverage == COVERAGE_FULL
|
|
56
|
+
except Exception:
|
|
57
|
+
return coverage == "full"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _db_path() -> Path:
|
|
61
|
+
return state_path("memory.db")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _active_profile() -> str:
|
|
65
|
+
"""Resolve the active profile without a daemon and without side effects.
|
|
66
|
+
|
|
67
|
+
Reads the ``active`` pointer out of ``profiles.json`` directly.
|
|
68
|
+
``ProfileManager`` would give the same answer, but its constructor calls
|
|
69
|
+
``mkdir(parents=True)`` — creating directories is not something a read-only
|
|
70
|
+
summary command should do. ``core.profiles`` also has no module-level
|
|
71
|
+
accessor; ``get_active_profile`` there is a method on the manager, and the
|
|
72
|
+
module-level one lives in ``server/routes/helpers.py``, which the CLI must
|
|
73
|
+
not import.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
from superlocalmemory.core.profiles import DEFAULT_PROFILES_FILE
|
|
77
|
+
|
|
78
|
+
path = state_path(DEFAULT_PROFILES_FILE)
|
|
79
|
+
if path.exists():
|
|
80
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
81
|
+
active = raw.get("active")
|
|
82
|
+
if isinstance(active, str) and active:
|
|
83
|
+
return active
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return "default"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_config() -> Any:
|
|
90
|
+
"""The active SLMConfig, so Mode B/C get LLM-written summaries.
|
|
91
|
+
|
|
92
|
+
Without this the generators receive ``config=None``, ``get_mode_str`` returns
|
|
93
|
+
``"a"``, and the LLM branch is unreachable — every summary comes out
|
|
94
|
+
extractive no matter which mode the user runs. That is not a graceful
|
|
95
|
+
fallback, it is the enrichment path never being offered.
|
|
96
|
+
|
|
97
|
+
Returns None on any failure, which lands on the extractive path. That is the
|
|
98
|
+
right fallback: a deterministic summary beats an error.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
from superlocalmemory.core.config import SLMConfig
|
|
102
|
+
|
|
103
|
+
return SLMConfig.load()
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _emit(result: Any, as_json: bool) -> None:
|
|
109
|
+
"""Print a SummaryResult as JSON or as prose."""
|
|
110
|
+
if as_json:
|
|
111
|
+
print(json.dumps({
|
|
112
|
+
"kind": result.kind,
|
|
113
|
+
"profile_id": result.profile_id,
|
|
114
|
+
"content": result.content,
|
|
115
|
+
"source_fact_ids": result.source_fact_ids,
|
|
116
|
+
"coverage": result.coverage,
|
|
117
|
+
"generated_by": result.generated_by,
|
|
118
|
+
"metadata": result.metadata,
|
|
119
|
+
}, indent=2, default=str))
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
print()
|
|
123
|
+
print(result.content.rstrip() or "(nothing recorded)")
|
|
124
|
+
print()
|
|
125
|
+
|
|
126
|
+
# Coverage is not decoration. A summary built from a fraction of the data
|
|
127
|
+
# that presents itself as the whole is the failure mode issue #113 called
|
|
128
|
+
# out by name, so it is stated on every single result, not only bad ones.
|
|
129
|
+
n = len(result.source_fact_ids)
|
|
130
|
+
line = f"Built from {n} memor{'y' if n == 1 else 'ies'} · coverage: {result.coverage}"
|
|
131
|
+
if not _coverage_is_complete(result.coverage):
|
|
132
|
+
line += " — treat as a partial view, not a complete record"
|
|
133
|
+
print(line)
|
|
134
|
+
if result.generated_by:
|
|
135
|
+
print(f"Method: {result.generated_by}")
|
|
136
|
+
print("Use --json to see the exact memories this came from.")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_summary(args: Namespace) -> None:
|
|
140
|
+
"""Dispatch ``slm summary <subcommand>``."""
|
|
141
|
+
sub = getattr(args, "summary_command", None)
|
|
142
|
+
as_json = bool(getattr(args, "json", False))
|
|
143
|
+
profile = getattr(args, "profile", None) or _active_profile()
|
|
144
|
+
db = _db_path()
|
|
145
|
+
cfg = _load_config() # Mode B/C enrichment; None -> extractive
|
|
146
|
+
|
|
147
|
+
if not db.exists():
|
|
148
|
+
print(f"No memory database at {db}. Run `slm status` first.")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if sub == "session":
|
|
152
|
+
from superlocalmemory.summaries import generate_session_summary
|
|
153
|
+
|
|
154
|
+
_emit(generate_session_summary(db, args.session_id, profile, cfg), as_json)
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
if sub == "day":
|
|
158
|
+
from superlocalmemory.summaries import generate_daily_reflection
|
|
159
|
+
|
|
160
|
+
target = getattr(args, "date", None) or date.today().isoformat()
|
|
161
|
+
if target == "yesterday":
|
|
162
|
+
target = (date.today() - timedelta(days=1)).isoformat()
|
|
163
|
+
elif target == "today":
|
|
164
|
+
target = date.today().isoformat()
|
|
165
|
+
_emit(generate_daily_reflection(db, target, profile, cfg), as_json)
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
if sub == "project":
|
|
169
|
+
from superlocalmemory.summaries import generate_project_work_log
|
|
170
|
+
|
|
171
|
+
path = getattr(args, "path", None) or str(Path.cwd())
|
|
172
|
+
_emit(generate_project_work_log(db, path, profile, cfg), as_json)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
print("Usage: slm summary {session <id> | day [DATE] | project [PATH]}")
|
|
176
|
+
print()
|
|
177
|
+
print(" slm summary day what you recorded today")
|
|
178
|
+
print(" slm summary day yesterday ...or yesterday")
|
|
179
|
+
print(" slm summary day 2026-08-17 ...or a specific date")
|
|
180
|
+
print(" slm summary project work log for the current directory")
|
|
181
|
+
print(" slm summary session <id> what one session covered")
|
|
182
|
+
print()
|
|
183
|
+
print("Add --json to include the ids of the memories a summary came from.")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def register_summary_parser(sub: Any) -> None:
|
|
187
|
+
"""Attach the ``summary`` parser. Called from cli/main.py."""
|
|
188
|
+
p = sub.add_parser(
|
|
189
|
+
"summary",
|
|
190
|
+
help="Readable summaries of your memories (session, day, project)",
|
|
191
|
+
)
|
|
192
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
193
|
+
p.add_argument("--profile", help="profile to summarise (default: active)")
|
|
194
|
+
ssub = p.add_subparsers(dest="summary_command", title="summary subcommands")
|
|
195
|
+
|
|
196
|
+
s = ssub.add_parser("session", help="what one session covered")
|
|
197
|
+
s.add_argument("session_id", help="session id (see `slm status`)")
|
|
198
|
+
s.add_argument("--json", action="store_true")
|
|
199
|
+
s.add_argument("--profile")
|
|
200
|
+
|
|
201
|
+
d = ssub.add_parser("day", help="what a day's main topics were")
|
|
202
|
+
d.add_argument(
|
|
203
|
+
"date", nargs="?",
|
|
204
|
+
help="YYYY-MM-DD, 'today' or 'yesterday' (default: today)",
|
|
205
|
+
)
|
|
206
|
+
d.add_argument("--json", action="store_true")
|
|
207
|
+
d.add_argument("--profile")
|
|
208
|
+
|
|
209
|
+
pr = ssub.add_parser("project", help="what was worked on in a project")
|
|
210
|
+
pr.add_argument(
|
|
211
|
+
"path", nargs="?",
|
|
212
|
+
help="project directory (default: current directory)",
|
|
213
|
+
)
|
|
214
|
+
pr.add_argument("--json", action="store_true")
|
|
215
|
+
pr.add_argument("--profile")
|
|
@@ -152,9 +152,25 @@ class EntityResolver:
|
|
|
152
152
|
self,
|
|
153
153
|
fact_text: str,
|
|
154
154
|
fact_id: str,
|
|
155
|
+
max_links: int | None = None,
|
|
155
156
|
) -> list[CodeMemoryLink]:
|
|
156
157
|
"""Resolve code entity mentions in fact text and create links.
|
|
157
158
|
|
|
159
|
+
Args:
|
|
160
|
+
fact_text: Text to scan for code mentions.
|
|
161
|
+
fact_id: The fact these links belong to.
|
|
162
|
+
max_links: Keep at most this many links, highest confidence first.
|
|
163
|
+
``None`` (the default) is unbounded, preserving the behaviour the
|
|
164
|
+
manual ``link_memory_to_code`` path relies on.
|
|
165
|
+
|
|
166
|
+
A bound matters for automatic resolution. One file-path mention
|
|
167
|
+
matches EVERY node in that file: the fact "the parser in
|
|
168
|
+
code_graph/parser.py was dropping edges" produced 17 links at
|
|
169
|
+
confidence 0.6-0.8, while a backticked function name produces one
|
|
170
|
+
at 0.95. Unbounded, a fact naming a few files buries its own
|
|
171
|
+
high-signal links and hands a large node set to HebbianLinker,
|
|
172
|
+
whose neighbourhood expansion then grows accordingly.
|
|
173
|
+
|
|
158
174
|
Returns list of CodeMemoryLink objects created.
|
|
159
175
|
"""
|
|
160
176
|
if not fact_text or not fact_id:
|
|
@@ -177,6 +193,16 @@ class EntityResolver:
|
|
|
177
193
|
if not matches:
|
|
178
194
|
return []
|
|
179
195
|
|
|
196
|
+
selected = list(matches.values())
|
|
197
|
+
if max_links is not None and len(selected) > max_links:
|
|
198
|
+
# Confidence ranks the match kinds correctly already — backticked and
|
|
199
|
+
# call-syntax mentions score 0.95, a bare identifier 0.9, file-path
|
|
200
|
+
# fan-out 0.6-0.8 — so ordering by it keeps the precise mentions and
|
|
201
|
+
# drops the broad ones.
|
|
202
|
+
selected.sort(key=lambda m: m.confidence, reverse=True)
|
|
203
|
+
selected = selected[:max_links]
|
|
204
|
+
matches = {m.node_id: m for m in selected}
|
|
205
|
+
|
|
180
206
|
# Classify link type
|
|
181
207
|
link_type = self._classify_link_type(fact_text)
|
|
182
208
|
now_str = datetime.now(timezone.utc).isoformat()
|
|
@@ -57,12 +57,23 @@ class BridgeEventListeners:
|
|
|
57
57
|
return self._started
|
|
58
58
|
|
|
59
59
|
def start(self, event_bus: Any) -> None:
|
|
60
|
-
"""Register
|
|
60
|
+
"""Register code-graph listeners on the event bus.
|
|
61
61
|
|
|
62
62
|
Registers:
|
|
63
|
-
- on_memory_stored: listens to "memory.stored"
|
|
64
63
|
- on_code_node_deleted: listens to "code_graph.node_deleted"
|
|
65
64
|
- on_code_node_changed: listens to "code_graph.node_changed"
|
|
65
|
+
|
|
66
|
+
DELIBERATELY NOT REGISTERED: ``on_memory_stored``.
|
|
67
|
+
``EventBus._notify_listeners`` invokes every listener synchronously on
|
|
68
|
+
the emitting thread, so subscribing to ``memory.stored`` would run entity
|
|
69
|
+
resolution, enrichment and Hebbian linking inside each ``remember`` —
|
|
70
|
+
putting all three on the write path. That work now runs in background
|
|
71
|
+
maintenance instead; see ``code_graph.bridge.maintenance``.
|
|
72
|
+
|
|
73
|
+
``on_memory_stored`` is kept as a callable so a caller that genuinely
|
|
74
|
+
wants synchronous linking for one fact can invoke it directly, but
|
|
75
|
+
nothing subscribes it to the bus. ``tests/test_code_graph/
|
|
76
|
+
test_bridge_off_write_path.py`` fails if that changes.
|
|
66
77
|
"""
|
|
67
78
|
if self._started:
|
|
68
79
|
logger.warning("BridgeEventListeners already started")
|
|
@@ -70,8 +81,8 @@ class BridgeEventListeners:
|
|
|
70
81
|
|
|
71
82
|
self._event_bus = event_bus
|
|
72
83
|
|
|
84
|
+
# Both events are emitted by code-graph builds, never by memory writes.
|
|
73
85
|
listeners: list[tuple[str, Callable[..., Any]]] = [
|
|
74
|
-
("memory.stored", self.on_memory_stored),
|
|
75
86
|
("code_graph.node_deleted", self.on_code_node_deleted),
|
|
76
87
|
("code_graph.node_changed", self.on_code_node_changed),
|
|
77
88
|
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""Bridge pass — runs the code↔memory bridge as background maintenance.
|
|
6
|
+
|
|
7
|
+
WHY THIS MODULE EXISTS
|
|
8
|
+
----------------------
|
|
9
|
+
The bridge was authored to run from ``BridgeEventListeners.on_memory_stored``,
|
|
10
|
+
i.e. once per ``memory.stored`` event. ``EventBus._notify_listeners`` calls
|
|
11
|
+
listeners **synchronously on the emitting thread**, so that design puts entity
|
|
12
|
+
resolution, enrichment and Hebbian linking inside every single ``remember``.
|
|
13
|
+
The owner's constraint for this release is explicit: remember and recall timing
|
|
14
|
+
must not move. So the memory-stored listener is gone and the work happens here,
|
|
15
|
+
in the same background pass that already runs ``consolidate_facts``.
|
|
16
|
+
|
|
17
|
+
WHAT THIS PASS TOUCHES
|
|
18
|
+
----------------------
|
|
19
|
+
Writes to ``code_graph.db`` only:
|
|
20
|
+
* ``code_memory_links`` — EntityResolver output
|
|
21
|
+
* ``code_memory_links.enriched_description`` — FactEnricher output
|
|
22
|
+
|
|
23
|
+
Recall never opens ``code_graph.db`` — verified: nothing under ``retrieval/`` or
|
|
24
|
+
``core/`` references ``code_memory_links`` or ``CodeGraphDatabase``. That makes
|
|
25
|
+
this half of the bridge recall-neutral by construction rather than by
|
|
26
|
+
measurement.
|
|
27
|
+
|
|
28
|
+
DELIBERATELY NOT HERE: Hebbian association edges. ``HebbianLinker`` produces
|
|
29
|
+
edges for ``association_edges`` in **memory.db**, which
|
|
30
|
+
``retrieval/spreading_activation.py`` reads via a UNION with ``graph_edges``.
|
|
31
|
+
Every such edge is an extra neighbour recall must traverse and changes which
|
|
32
|
+
memories come back — not just how fast. That cannot be made safe by moving it
|
|
33
|
+
into this pass, so it is deferred to its own release, where the edge volume at
|
|
34
|
+
production scale can be measured against the recall baseline first. No writer for
|
|
35
|
+
it ships here; ``bridge/hebbian_linker.py`` remains unwired on purpose.
|
|
36
|
+
|
|
37
|
+
IDEMPOTENCE
|
|
38
|
+
-----------
|
|
39
|
+
A watermark in ``graph_metadata`` records the ``created_at`` of the newest fact
|
|
40
|
+
processed. Re-running the pass processes only facts newer than that, so a
|
|
41
|
+
maintenance cycle every few minutes does not rescan the whole store. Links use
|
|
42
|
+
``INSERT OR REPLACE`` on a deterministic key, so reprocessing a fact cannot
|
|
43
|
+
duplicate its links.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import logging
|
|
49
|
+
from typing import TYPE_CHECKING, Any
|
|
50
|
+
|
|
51
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
52
|
+
from superlocalmemory.code_graph.database import CodeGraphDatabase
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
#: Watermark key in graph_metadata.
|
|
57
|
+
_WATERMARK_KEY = "bridge.last_fact_created_at"
|
|
58
|
+
|
|
59
|
+
#: Facts examined in a single pass. Bounds the pass so a first run on a large
|
|
60
|
+
#: store cannot occupy the maintenance thread indefinitely; the watermark means
|
|
61
|
+
#: the next cycle resumes where this one stopped.
|
|
62
|
+
MAX_FACTS_PER_PASS = 500
|
|
63
|
+
|
|
64
|
+
#: Links kept for one fact, highest confidence first. A single file-path mention
|
|
65
|
+
#: matches every node in that file — "the parser in code_graph/parser.py was
|
|
66
|
+
#: dropping edges" produced 17 links at confidence 0.6-0.8, against one link at
|
|
67
|
+
#: 0.95 for a backticked function name. Without a bound the broad matches bury
|
|
68
|
+
#: the precise ones in the UI and hand a large node set to the Hebbian pass.
|
|
69
|
+
MAX_LINKS_PER_FACT = 10
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fact_rows(
|
|
73
|
+
memory_db: Any,
|
|
74
|
+
profile_id: str,
|
|
75
|
+
since: str | None,
|
|
76
|
+
limit: int,
|
|
77
|
+
) -> list[tuple[str, str, str]]:
|
|
78
|
+
"""Return (fact_id, content, created_at) for facts newer than *since*.
|
|
79
|
+
|
|
80
|
+
Ordered by ``created_at`` so the watermark advances monotonically even when
|
|
81
|
+
the pass stops at ``limit``.
|
|
82
|
+
"""
|
|
83
|
+
sql = (
|
|
84
|
+
"SELECT fact_id, content, created_at FROM atomic_facts "
|
|
85
|
+
"WHERE profile_id = ? AND content IS NOT NULL AND content != '' "
|
|
86
|
+
)
|
|
87
|
+
# M011 (archive_status) is a DEFERRED migration, so the column is absent on
|
|
88
|
+
# a database where it has not run yet. DatabaseManager._has_archive_status
|
|
89
|
+
# exists for exactly this and its docstring is explicit: "callers must not
|
|
90
|
+
# filter on a column that may not exist." Filtering unconditionally made
|
|
91
|
+
# this query raise "no such column" on any fresh install, which the caller's
|
|
92
|
+
# except swallowed into a warning and zero links — the bridge would simply
|
|
93
|
+
# never have run for a new user.
|
|
94
|
+
try:
|
|
95
|
+
has_archive = memory_db._has_archive_status()
|
|
96
|
+
except Exception: # pragma: no cover - helper absent on an unusual manager
|
|
97
|
+
has_archive = False
|
|
98
|
+
if has_archive:
|
|
99
|
+
# COALESCE form, matching storage/database.py:759 and the learning
|
|
100
|
+
# modules. My first version tested `IS NULL OR = ''`, which excluded
|
|
101
|
+
# every fact on a real store: live facts are marked 'live', not blank.
|
|
102
|
+
# The synthetic fixtures I developed against left the column unset, so
|
|
103
|
+
# the pass linked happily in tests and would have linked NOTHING for any
|
|
104
|
+
# actual user — 3,608 of 3,608 facts filtered out on this machine.
|
|
105
|
+
sql += "AND COALESCE(archive_status, 'live') != 'archived' "
|
|
106
|
+
|
|
107
|
+
params: list[Any] = [profile_id]
|
|
108
|
+
if since:
|
|
109
|
+
sql += "AND created_at > ? "
|
|
110
|
+
params.append(since)
|
|
111
|
+
sql += "ORDER BY created_at ASC LIMIT ?"
|
|
112
|
+
params.append(limit)
|
|
113
|
+
|
|
114
|
+
# DatabaseManager.execute serves both reads and writes (see
|
|
115
|
+
# core/maintenance.py, which uses it for each). It returns sqlite3.Row;
|
|
116
|
+
# index by position so a plain-tuple factory also works.
|
|
117
|
+
rows = memory_db.execute(sql, tuple(params))
|
|
118
|
+
return [(r[0], r[1], r[2]) for r in rows]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_bridge_pass(
|
|
122
|
+
memory_db: Any,
|
|
123
|
+
code_graph_db: CodeGraphDatabase,
|
|
124
|
+
profile_id: str,
|
|
125
|
+
*,
|
|
126
|
+
max_facts: int = MAX_FACTS_PER_PASS,
|
|
127
|
+
) -> dict[str, int]:
|
|
128
|
+
"""Resolve code mentions in new facts and enrich the resulting links.
|
|
129
|
+
|
|
130
|
+
Returns counts. Never raises — the caller is background maintenance and a
|
|
131
|
+
bridge failure must not abort the rest of the cycle.
|
|
132
|
+
"""
|
|
133
|
+
counts = {"facts_scanned": 0, "links_created": 0, "enriched": 0}
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
from superlocalmemory.code_graph.bridge.entity_resolver import EntityResolver
|
|
137
|
+
from superlocalmemory.code_graph.bridge.fact_enricher import FactEnricher
|
|
138
|
+
except Exception as exc: # pragma: no cover - import guard
|
|
139
|
+
logger.debug("bridge pass unavailable: %s", exc)
|
|
140
|
+
return counts
|
|
141
|
+
|
|
142
|
+
# Nothing to match against — skip before touching memory.db at all.
|
|
143
|
+
stats = code_graph_db.get_stats()
|
|
144
|
+
if not stats.get("nodes"):
|
|
145
|
+
logger.debug("bridge pass: code graph is empty, nothing to resolve against")
|
|
146
|
+
return counts
|
|
147
|
+
|
|
148
|
+
watermark = code_graph_db.get_metadata(_WATERMARK_KEY)
|
|
149
|
+
try:
|
|
150
|
+
rows = _fact_rows(memory_db, profile_id, watermark, max_facts)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.warning("bridge pass could not read facts: %s", exc)
|
|
153
|
+
return counts
|
|
154
|
+
|
|
155
|
+
if not rows:
|
|
156
|
+
return counts
|
|
157
|
+
|
|
158
|
+
resolver = EntityResolver(code_graph_db)
|
|
159
|
+
enricher = FactEnricher(code_graph_db)
|
|
160
|
+
newest = watermark
|
|
161
|
+
|
|
162
|
+
for fact_id, content, created_at in rows:
|
|
163
|
+
counts["facts_scanned"] += 1
|
|
164
|
+
newest = created_at if newest is None or created_at > newest else newest
|
|
165
|
+
try:
|
|
166
|
+
links = resolver.resolve(content, fact_id, max_links=MAX_LINKS_PER_FACT)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
logger.debug("bridge resolve failed for %s: %s", fact_id, exc)
|
|
169
|
+
continue
|
|
170
|
+
if not links:
|
|
171
|
+
continue
|
|
172
|
+
counts["links_created"] += len(links)
|
|
173
|
+
|
|
174
|
+
# Enrichment is derived from (fact text, matched nodes) and is stored
|
|
175
|
+
# beside the link in code_graph.db. The user's own fact wording in
|
|
176
|
+
# memory.db is never rewritten: doing that would invalidate the fact's
|
|
177
|
+
# embedding, and would compound a suffix on every maintenance cycle.
|
|
178
|
+
try:
|
|
179
|
+
matched = resolver.get_matched_nodes(content)
|
|
180
|
+
if not matched:
|
|
181
|
+
continue
|
|
182
|
+
enriched = enricher.enrich(fact_id, matched, content)
|
|
183
|
+
if enriched and enriched != content:
|
|
184
|
+
_store_enrichment(code_graph_db, fact_id, enriched)
|
|
185
|
+
counts["enriched"] += 1
|
|
186
|
+
except Exception as exc:
|
|
187
|
+
logger.debug("bridge enrichment failed for %s: %s", fact_id, exc)
|
|
188
|
+
|
|
189
|
+
if newest and newest != watermark:
|
|
190
|
+
try:
|
|
191
|
+
code_graph_db.set_metadata(_WATERMARK_KEY, newest)
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logger.warning("bridge watermark not advanced: %s", exc)
|
|
194
|
+
|
|
195
|
+
# One summary line per pass, never one per fact. A 3,527-fact store must not
|
|
196
|
+
# produce 3,527 log lines; per-fact detail stays at debug.
|
|
197
|
+
if counts["links_created"]:
|
|
198
|
+
logger.info(
|
|
199
|
+
"Code bridge: %d facts scanned, %d links, %d enriched",
|
|
200
|
+
counts["facts_scanned"], counts["links_created"], counts["enriched"],
|
|
201
|
+
)
|
|
202
|
+
return counts
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _store_enrichment(
|
|
206
|
+
code_graph_db: CodeGraphDatabase, fact_id: str, enriched: str
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Persist enrichment text onto every link for *fact_id*."""
|
|
209
|
+
code_graph_db.execute_write(
|
|
210
|
+
"UPDATE code_memory_links SET enriched_description = ? WHERE slm_fact_id = ?",
|
|
211
|
+
(enriched, fact_id),
|
|
212
|
+
)
|
|
@@ -9,7 +9,7 @@ Frozen dataclass with all tunables. Sensible defaults for typical repos.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
from dataclasses import dataclass, field
|
|
12
|
+
from dataclasses import dataclass, field, fields
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
from superlocalmemory.infra.data_root import state_path
|
|
@@ -88,3 +88,67 @@ class CodeGraphConfig:
|
|
|
88
88
|
if slm_base_dir is not None:
|
|
89
89
|
return slm_base_dir / "code_graph.db"
|
|
90
90
|
return state_path("code_graph.db")
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def load(cls, **overrides: object) -> CodeGraphConfig:
|
|
94
|
+
"""Build a config from ``code_graph_config.json``, then apply overrides.
|
|
95
|
+
|
|
96
|
+
WHY THIS EXISTS (4.0.7). ``cli/setup_wizard.py`` has always written
|
|
97
|
+
``code_graph_config.json`` with ``enabled`` and ``bridge_enabled``, and
|
|
98
|
+
until now **nothing read it**. There was no loader on this class at all,
|
|
99
|
+
and every call site constructed ``CodeGraphConfig(enabled=True)`` with
|
|
100
|
+
hardcoded defaults. So a user could answer "yes, enable the code graph"
|
|
101
|
+
in setup, get ``bridge_enabled: true`` written to disk, and have it
|
|
102
|
+
affect nothing — silently, with no way to tell from the outside.
|
|
103
|
+
|
|
104
|
+
Unknown keys in the file are ignored rather than raising: the file is
|
|
105
|
+
user-editable, and a stray key should not stop the code graph from
|
|
106
|
+
loading. Malformed JSON falls back to defaults with a warning, because
|
|
107
|
+
failing closed here would disable a working code graph over a typo.
|
|
108
|
+
"""
|
|
109
|
+
import json
|
|
110
|
+
import logging
|
|
111
|
+
|
|
112
|
+
data: dict[str, object] = {}
|
|
113
|
+
path = state_path("code_graph_config.json")
|
|
114
|
+
try:
|
|
115
|
+
if path.exists():
|
|
116
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
117
|
+
if isinstance(loaded, dict):
|
|
118
|
+
data = loaded
|
|
119
|
+
else:
|
|
120
|
+
logging.getLogger(__name__).warning(
|
|
121
|
+
"%s does not contain a JSON object; using defaults", path,
|
|
122
|
+
)
|
|
123
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
124
|
+
logging.getLogger(__name__).warning(
|
|
125
|
+
"could not read %s (%s); using defaults", path, exc,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
data.update(overrides)
|
|
129
|
+
|
|
130
|
+
valid = {f.name for f in fields(cls)}
|
|
131
|
+
unknown = sorted(set(data) - valid)
|
|
132
|
+
if unknown:
|
|
133
|
+
logging.getLogger(__name__).debug(
|
|
134
|
+
"ignoring unknown code_graph config keys: %s", ", ".join(unknown),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
kwargs = {k: v for k, v in data.items() if k in valid}
|
|
138
|
+
|
|
139
|
+
# JSON has no frozenset/Path; coerce the fields that need it.
|
|
140
|
+
if isinstance(kwargs.get("languages"), list):
|
|
141
|
+
kwargs["languages"] = frozenset(kwargs["languages"])
|
|
142
|
+
if isinstance(kwargs.get("exclude_dirs"), list):
|
|
143
|
+
kwargs["exclude_dirs"] = frozenset(kwargs["exclude_dirs"])
|
|
144
|
+
for key in ("repo_root", "db_path"):
|
|
145
|
+
if isinstance(kwargs.get(key), str):
|
|
146
|
+
kwargs[key] = Path(kwargs[key])
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
return cls(**kwargs) # type: ignore[arg-type]
|
|
150
|
+
except TypeError as exc:
|
|
151
|
+
logging.getLogger(__name__).warning(
|
|
152
|
+
"code_graph config rejected (%s); using defaults", exc,
|
|
153
|
+
)
|
|
154
|
+
return cls()
|
|
@@ -198,27 +198,26 @@ class ConsolidationEngine:
|
|
|
198
198
|
from superlocalmemory.parameterization.prompt_injector import PromptInjector
|
|
199
199
|
from superlocalmemory.parameterization.prompt_lifecycle import PromptLifecycleManager
|
|
200
200
|
from superlocalmemory.learning.behavioral import BehavioralPatternStore
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
from superlocalmemory.parameterization.cross_project import CrossProjectAggregator
|
|
204
|
-
except ImportError:
|
|
205
|
-
class CrossProjectAggregator:
|
|
206
|
-
def __init__(self, db): pass
|
|
207
|
-
def get_preferences(self, *a, **kw): return {}
|
|
208
|
-
try:
|
|
209
|
-
from superlocalmemory.parameterization.workflow_miner import WorkflowMiner
|
|
210
|
-
except ImportError:
|
|
211
|
-
class WorkflowMiner:
|
|
212
|
-
def __init__(self, db): pass
|
|
213
|
-
def mine(self, *a, **kw): return []
|
|
201
|
+
from superlocalmemory.parameterization.cross_project import CrossProjectAggregator
|
|
202
|
+
from superlocalmemory.parameterization.workflow_miner import WorkflowMiner
|
|
214
203
|
from superlocalmemory.hooks.auto_parameterize import AutoParameterizeHook
|
|
215
204
|
from superlocalmemory.core.config import ParameterizationConfig
|
|
216
205
|
p_config = getattr(self._config, "parameterization", ParameterizationConfig())
|
|
217
206
|
from superlocalmemory.infra.data_root import state_path
|
|
218
207
|
learning_db = str(state_path("learning.db"))
|
|
219
208
|
beh_store = BehavioralPatternStore(learning_db)
|
|
220
|
-
|
|
221
|
-
|
|
209
|
+
# Both take a DB PATH, not a DatabaseManager. Passing
|
|
210
|
+
# self._db here made Path(db_path) raise "argument should be
|
|
211
|
+
# a str or an os.PathLike object ... not 'DatabaseManager'",
|
|
212
|
+
# which step 9 caught at debug level — so soft prompts were
|
|
213
|
+
# never generated and nothing said why.
|
|
214
|
+
#
|
|
215
|
+
# The ImportError stubs these replaced were also dead: both
|
|
216
|
+
# modules exist and re-export the canonical classes, so the
|
|
217
|
+
# stub branch could never run and only served to hide the
|
|
218
|
+
# real signatures.
|
|
219
|
+
cross_proj = CrossProjectAggregator(learning_db)
|
|
220
|
+
wf_miner = WorkflowMiner(learning_db)
|
|
222
221
|
extractor = PatternExtractor(self._db, beh_store, cross_proj, wf_miner, p_config)
|
|
223
222
|
generator = SoftPromptGenerator(p_config)
|
|
224
223
|
injector = PromptInjector(self._db, generator, p_config)
|
|
@@ -159,7 +159,30 @@ def consolidate_facts(
|
|
|
159
159
|
stats["error_detail"] = str(exc)
|
|
160
160
|
return stats
|
|
161
161
|
|
|
162
|
-
# Backward-compat: str | Path — open own connection.
|
|
162
|
+
# Backward-compat: str | Path — open our own connection.
|
|
163
|
+
#
|
|
164
|
+
# Type-checked, not assumed. This branch used to run for ANYTHING that was
|
|
165
|
+
# not a DatabaseManager, stringify it, and hand the result to
|
|
166
|
+
# sqlite3.connect — which creates whatever filename it is given. A test
|
|
167
|
+
# passing a MagicMock therefore had a real 4 KB SQLite file named
|
|
168
|
+
# "<MagicMock id='4422448000'>" written into the repository root, one per
|
|
169
|
+
# test. 42 of them had accumulated, and tests/test_ci_guards/
|
|
170
|
+
# test_no_magicmock_artifacts.py failed after any full-suite run.
|
|
171
|
+
#
|
|
172
|
+
# 4.0.6 is where this started firing: it wired consolidate_facts into
|
|
173
|
+
# run_maintenance, so every caller with a mock config reached this line.
|
|
174
|
+
#
|
|
175
|
+
# Refusing an unusable argument is also right beyond the test symptom —
|
|
176
|
+
# silently creating a database at a nonsense path cannot be what any caller
|
|
177
|
+
# wanted, and it hides the real bug (the caller passed the wrong thing).
|
|
178
|
+
if not isinstance(db_or_path, (str, Path)):
|
|
179
|
+
raise TypeError(
|
|
180
|
+
"consolidate_facts() expects a DatabaseManager, or a str/Path to "
|
|
181
|
+
f"memory.db for backward compatibility; got {type(db_or_path).__name__}. "
|
|
182
|
+
"Passing anything else previously created a database file named after "
|
|
183
|
+
"the object's repr."
|
|
184
|
+
)
|
|
185
|
+
|
|
163
186
|
logger.warning(
|
|
164
187
|
"consolidate_facts: passing a db_path is deprecated — pass a "
|
|
165
188
|
"DatabaseManager instead (Fix A backward-compat shim active)"
|