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
|
@@ -418,6 +418,10 @@ class EngineRecallAdapter:
|
|
|
418
418
|
memory_map={k: _sanitize_json_text(v) for k, v in memory_map.items()},
|
|
419
419
|
per_fact_max=getattr(_rc, "recall_per_fact_max_chars", 2400),
|
|
420
420
|
total_max=getattr(_rc, "recall_total_max_chars", 12000),
|
|
421
|
+
# Option B: markers only on session-bearing recalls. A marker can
|
|
422
|
+
# only buy a learning signal when a pending_outcomes row exists
|
|
423
|
+
# to settle, and those exist only when session_id is present.
|
|
424
|
+
include_marker=bool(session_id),
|
|
421
425
|
)
|
|
422
426
|
for _r in results:
|
|
423
427
|
_r["content"] = _sanitize_json_text(_r.get("content", ""))
|
|
@@ -860,6 +864,86 @@ def _start_idle_watchdog(timeout_sec: int) -> None:
|
|
|
860
864
|
t.start()
|
|
861
865
|
|
|
862
866
|
|
|
867
|
+
# ---------------------------------------------------------------------------
|
|
868
|
+
# Periodic full consolidation (4.0.8)
|
|
869
|
+
# ---------------------------------------------------------------------------
|
|
870
|
+
#
|
|
871
|
+
# Steps 8-11 of ConsolidationEngine — behavioural assertions, soft prompts,
|
|
872
|
+
# skill performance, skill evolution — had no automatic trigger at all. The
|
|
873
|
+
# session-end hook is the fast path; this timer is the guarantee, because a hook
|
|
874
|
+
# that does not fire is indistinguishable from a feature that does not exist.
|
|
875
|
+
#
|
|
876
|
+
# Two constraints shape the schedule:
|
|
877
|
+
#
|
|
878
|
+
# 1. Remember and recall latency must not move. So the pass only starts when
|
|
879
|
+
# the daemon has served nothing for _CONSOLIDATION_IDLE_SEC. Consolidation
|
|
880
|
+
# is catch-up work; there is never a reason for it to compete with a live
|
|
881
|
+
# request. If the machine is busy every time we look, we simply skip and
|
|
882
|
+
# check again next tick.
|
|
883
|
+
# 2. It must not pile up. run_full_consolidation() holds a lock and skips
|
|
884
|
+
# rather than queues, so a slow pass cannot be overlapped by the next tick
|
|
885
|
+
# or by the session-end hook.
|
|
886
|
+
|
|
887
|
+
#: How often to consider running. Not how often it runs.
|
|
888
|
+
_CONSOLIDATION_CHECK_SEC = int(os.environ.get("SLM_CONSOLIDATION_CHECK_SEC", 900))
|
|
889
|
+
|
|
890
|
+
#: Minimum quiet period before a scheduled pass may start.
|
|
891
|
+
_CONSOLIDATION_IDLE_SEC = int(os.environ.get("SLM_CONSOLIDATION_IDLE_SEC", 300))
|
|
892
|
+
|
|
893
|
+
#: Minimum gap between two scheduled passes.
|
|
894
|
+
_CONSOLIDATION_MIN_GAP_SEC = int(
|
|
895
|
+
os.environ.get("SLM_CONSOLIDATION_MIN_GAP_SEC", 6 * 3600)
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
#: Delay before the first check, so daemon startup is never slowed by it.
|
|
899
|
+
_CONSOLIDATION_FIRST_DELAY_SEC = int(
|
|
900
|
+
os.environ.get("SLM_CONSOLIDATION_FIRST_DELAY_SEC", 120)
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
async def _consolidation_timer_loop(application: FastAPI) -> None:
|
|
905
|
+
"""Run full consolidation on a schedule, but only while the daemon is idle."""
|
|
906
|
+
from superlocalmemory.server.consolidation_runner import run_full_consolidation
|
|
907
|
+
|
|
908
|
+
await asyncio.sleep(_CONSOLIDATION_FIRST_DELAY_SEC)
|
|
909
|
+
last_run = 0.0
|
|
910
|
+
|
|
911
|
+
while True:
|
|
912
|
+
try:
|
|
913
|
+
await asyncio.sleep(_CONSOLIDATION_CHECK_SEC)
|
|
914
|
+
|
|
915
|
+
now = time.monotonic()
|
|
916
|
+
if last_run and (now - last_run) < _CONSOLIDATION_MIN_GAP_SEC:
|
|
917
|
+
continue
|
|
918
|
+
if (now - _last_activity) < _CONSOLIDATION_IDLE_SEC:
|
|
919
|
+
continue # busy — try again next tick
|
|
920
|
+
|
|
921
|
+
# get_engine_lazy, not state.engine directly: a mode switch nulls
|
|
922
|
+
# state.engine, and reading it raw would silently disable scheduled
|
|
923
|
+
# consolidation until the next daemon restart.
|
|
924
|
+
from superlocalmemory.server.routes.helpers import get_engine_lazy
|
|
925
|
+
|
|
926
|
+
engine = get_engine_lazy(application.state)
|
|
927
|
+
profile_id = getattr(engine, "profile_id", None) if engine else None
|
|
928
|
+
if not profile_id:
|
|
929
|
+
continue
|
|
930
|
+
|
|
931
|
+
result = await run_full_consolidation(
|
|
932
|
+
application.state, profile_id, trigger="timer",
|
|
933
|
+
)
|
|
934
|
+
# Only a pass that actually ran resets the clock; a skip must not
|
|
935
|
+
# buy another six hours of silence.
|
|
936
|
+
if not result.get("skipped"):
|
|
937
|
+
last_run = time.monotonic()
|
|
938
|
+
except asyncio.CancelledError:
|
|
939
|
+
raise
|
|
940
|
+
except Exception:
|
|
941
|
+
# Never let a bad pass kill the loop — that would silently disable
|
|
942
|
+
# consolidation for the life of the daemon, which is the failure
|
|
943
|
+
# mode this timer exists to end. Log loudly and try again.
|
|
944
|
+
logger.exception("scheduled consolidation failed; will retry")
|
|
945
|
+
|
|
946
|
+
|
|
863
947
|
# ---------------------------------------------------------------------------
|
|
864
948
|
# Legacy port TCP redirect (backward compat for port 8767)
|
|
865
949
|
# ---------------------------------------------------------------------------
|
|
@@ -2462,6 +2546,15 @@ async def lifespan(application: FastAPI):
|
|
|
2462
2546
|
except Exception as e:
|
|
2463
2547
|
logger.warning("optimize module not available: %s", e)
|
|
2464
2548
|
|
|
2549
|
+
# 4.0.8: periodic full consolidation. Idle-gated and lock-guarded — see
|
|
2550
|
+
# _consolidation_timer_loop. Started here rather than at import so a daemon
|
|
2551
|
+
# that never completes startup never schedules work.
|
|
2552
|
+
_consol_task = getattr(application.state, "_consolidation_task", None)
|
|
2553
|
+
if _consol_task is None or _consol_task.done():
|
|
2554
|
+
application.state._consolidation_task = asyncio.create_task(
|
|
2555
|
+
_consolidation_timer_loop(application)
|
|
2556
|
+
)
|
|
2557
|
+
|
|
2465
2558
|
# v3.6.7: Start MCP Streamable-HTTP session manager (GOTCHA #1).
|
|
2466
2559
|
# streamable_http_app() carries its own Starlette lifespan that initialises
|
|
2467
2560
|
# an anyio task group inside the session manager. Without entering that
|
|
@@ -2537,6 +2630,16 @@ async def lifespan(application: FastAPI):
|
|
|
2537
2630
|
except Exception: # pragma: no cover — defensive
|
|
2538
2631
|
pass
|
|
2539
2632
|
|
|
2633
|
+
# Cancel the periodic consolidation loop. Not awaited to completion — a pass
|
|
2634
|
+
# can take minutes and shutdown must not block on it; the lock and the
|
|
2635
|
+
# engine's own transaction boundaries make an interrupted pass safe to redo.
|
|
2636
|
+
try:
|
|
2637
|
+
_consol = getattr(application.state, "_consolidation_task", None)
|
|
2638
|
+
if _consol is not None and not _consol.done():
|
|
2639
|
+
_consol.cancel()
|
|
2640
|
+
except Exception: # pragma: no cover — defensive
|
|
2641
|
+
pass
|
|
2642
|
+
|
|
2540
2643
|
# Cancel optimize metrics flush loop + run final flush before shutdown
|
|
2541
2644
|
try:
|
|
2542
2645
|
_flush_task = getattr(application.state, "_optimize_flush_task", None)
|
|
@@ -3879,6 +3982,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3879
3982
|
memory_map={k: _sanitize_json_text(v) for k, v in memory_map.items()},
|
|
3880
3983
|
per_fact_max=getattr(_rc, "recall_per_fact_max_chars", 2400),
|
|
3881
3984
|
total_max=getattr(_rc, "recall_total_max_chars", 12000),
|
|
3985
|
+
# Option B: markers only on session-bearing recalls. A marker can
|
|
3986
|
+
# only buy a learning signal when a pending_outcomes row exists
|
|
3987
|
+
# to settle, and those exist only when session_id is present.
|
|
3988
|
+
include_marker=bool(session_id),
|
|
3882
3989
|
full=full,
|
|
3883
3990
|
include_source=include_source,
|
|
3884
3991
|
)
|
|
@@ -104,11 +104,49 @@ _DDL_STATEMENTS: tuple[str, ...] = (
|
|
|
104
104
|
confidence REAL NOT NULL DEFAULT 0.8 CHECK (confidence >= 0.0 AND confidence <= 1.0),
|
|
105
105
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
106
106
|
last_verified TEXT,
|
|
107
|
-
is_stale INTEGER NOT NULL DEFAULT 0
|
|
107
|
+
is_stale INTEGER NOT NULL DEFAULT 0,
|
|
108
|
+
enriched_description TEXT
|
|
108
109
|
)
|
|
109
110
|
""",
|
|
110
111
|
)
|
|
111
112
|
|
|
113
|
+
#: Columns added to existing tables after their first release.
|
|
114
|
+
#:
|
|
115
|
+
#: This file's DDL is all ``CREATE TABLE IF NOT EXISTS`` and code_graph.db has
|
|
116
|
+
#: no migration framework, so appending a column to a _DDL_STATEMENTS block
|
|
117
|
+
#: reaches NEW databases only — every database created by an earlier version
|
|
118
|
+
#: skips the statement entirely and never gains the column. That silent
|
|
119
|
+
#: divergence is what this list exists to close.
|
|
120
|
+
#:
|
|
121
|
+
#: ADDITIVE ONLY: ``ALTER TABLE ... ADD COLUMN`` with no NOT NULL and no
|
|
122
|
+
#: default, so it cannot fail on a populated table and cannot rewrite a row.
|
|
123
|
+
#: Never put a DROP, a RENAME, or a type change here.
|
|
124
|
+
_ADDITIVE_COLUMNS: tuple[tuple[str, str, str], ...] = (
|
|
125
|
+
# (table, column, type) — enrichment text for a code↔memory link. Lives
|
|
126
|
+
# here rather than in memory.db so the user's own fact wording is never
|
|
127
|
+
# overwritten, and so recall (which never opens code_graph.db) is unaffected.
|
|
128
|
+
("code_memory_links", "enriched_description", "TEXT"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _apply_additive_columns(cursor: sqlite3.Cursor) -> None:
|
|
133
|
+
"""Add any missing column from _ADDITIVE_COLUMNS. Idempotent."""
|
|
134
|
+
for table, column, coltype in _ADDITIVE_COLUMNS:
|
|
135
|
+
try:
|
|
136
|
+
existing = {row[1] for row in cursor.execute(f"PRAGMA table_info({table})")}
|
|
137
|
+
except sqlite3.Error as exc: # table absent on a partial database
|
|
138
|
+
logger.debug("additive column probe skipped for %s: %s", table, exc)
|
|
139
|
+
continue
|
|
140
|
+
if not existing or column in existing:
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {coltype}")
|
|
144
|
+
logger.info("code_graph schema: added %s.%s", table, column)
|
|
145
|
+
except sqlite3.OperationalError as exc:
|
|
146
|
+
# Concurrent initialiser won the race, or the column appeared
|
|
147
|
+
# between the probe and the ALTER. Both are benign.
|
|
148
|
+
logger.debug("additive column %s.%s not applied: %s", table, column, exc)
|
|
149
|
+
|
|
112
150
|
# Indexes (separate from tables for clarity)
|
|
113
151
|
_INDEX_STATEMENTS: tuple[str, ...] = (
|
|
114
152
|
# graph_nodes indexes
|
|
@@ -194,6 +232,11 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
|
|
|
194
232
|
for ddl in _DDL_STATEMENTS:
|
|
195
233
|
cursor.execute(ddl)
|
|
196
234
|
|
|
235
|
+
# Columns added after a table's first release. Must run AFTER the CREATEs
|
|
236
|
+
# (so a fresh database already has them and this is a no-op) and BEFORE the
|
|
237
|
+
# indexes (in case one is ever declared on an added column).
|
|
238
|
+
_apply_additive_columns(cursor)
|
|
239
|
+
|
|
197
240
|
# Indexes
|
|
198
241
|
for idx in _INDEX_STATEMENTS:
|
|
199
242
|
cursor.execute(idx)
|
|
@@ -98,6 +98,165 @@ GENERATED_BY_LLM_C = "llm_c"
|
|
|
98
98
|
"""Cloud LLM (Mode C). Falls back via llm_b to extractive."""
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
# ── highlight formatting ────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
#: Display width for one bullet in a summary body.
|
|
104
|
+
#:
|
|
105
|
+
#: Chosen for a bullet, not for a paragraph. The generators originally truncated
|
|
106
|
+
#: at 300 characters and nothing else, which looks fine on a synthetic corpus of
|
|
107
|
+
#: one-line facts and falls apart on a real store: agent-written facts routinely
|
|
108
|
+
#: contain blank lines and markdown headings, so a 300-character slice rendered
|
|
109
|
+
#: as six or more display lines and the bullet list stopped being a list.
|
|
110
|
+
HIGHLIGHT_CHARS = 180
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def format_highlight(content: str, limit: int = HIGHLIGHT_CHARS) -> str:
|
|
114
|
+
"""Collapse *content* to a single readable line for a summary bullet.
|
|
115
|
+
|
|
116
|
+
Three things, in order:
|
|
117
|
+
|
|
118
|
+
1. **Flatten whitespace.** Newlines, blank lines and runs of spaces all
|
|
119
|
+
become one space. This is the fix that matters: character truncation
|
|
120
|
+
alone cannot keep a multi-paragraph fact on one line, and every
|
|
121
|
+
generator here writes into a bullet list.
|
|
122
|
+
2. **Prefer a whole first sentence** when there is one and it fits. A
|
|
123
|
+
complete sentence reads better than a slice of one, and the first
|
|
124
|
+
sentence of a report is usually its summary.
|
|
125
|
+
3. **Otherwise cut at a word boundary** and mark the cut with an ellipsis,
|
|
126
|
+
so it is visible that text was dropped rather than that a fact ended
|
|
127
|
+
mid-word.
|
|
128
|
+
|
|
129
|
+
Markdown heading markers are stripped because a flattened ``**Summary**``
|
|
130
|
+
mid-sentence reads as noise.
|
|
131
|
+
"""
|
|
132
|
+
import re
|
|
133
|
+
|
|
134
|
+
text = re.sub(r"\s+", " ", (content or "")).strip()
|
|
135
|
+
# Leading/inline markdown emphasis and heading marks, once flattened, add
|
|
136
|
+
# nothing but clutter to a one-line bullet.
|
|
137
|
+
text = re.sub(r"(?:^|\s)#{1,6}\s+", " ", text)
|
|
138
|
+
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
|
139
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
140
|
+
|
|
141
|
+
if not text:
|
|
142
|
+
return ""
|
|
143
|
+
if len(text) <= limit:
|
|
144
|
+
return text
|
|
145
|
+
|
|
146
|
+
# A complete first sentence, if it fits comfortably.
|
|
147
|
+
match = re.match(r"(.+?[.!?])(?:\s|$)", text)
|
|
148
|
+
if match:
|
|
149
|
+
sentence = match.group(1).strip()
|
|
150
|
+
if len(sentence) <= limit:
|
|
151
|
+
return sentence
|
|
152
|
+
|
|
153
|
+
cut = text[:limit]
|
|
154
|
+
space = cut.rfind(" ")
|
|
155
|
+
if space > limit * 0.6: # don't cut a long unbroken token to a stub
|
|
156
|
+
cut = cut[:space]
|
|
157
|
+
return cut.rstrip(" ,;:—-") + "…"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── LLM output cleanup ──────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
#: Sentences a chat-tuned model emits *around* the answer rather than as part of
|
|
163
|
+
#: it. Anchored to the start of a paragraph so they cannot match mid-content.
|
|
164
|
+
#:
|
|
165
|
+
#: Measured, not guessed: a Mode B summary on the author's own store opened with
|
|
166
|
+
#: "I apologize for the previous confusion. It seems that I misunderstood the
|
|
167
|
+
#: context of the texts provided.\n\nTo provide a concise summary paragraph, here
|
|
168
|
+
#: is a merge of all the key information:" — 180 characters of the model talking
|
|
169
|
+
#: to itself, shown to the user as their daily reflection.
|
|
170
|
+
_LLM_PREAMBLE = (
|
|
171
|
+
r"^(?:"
|
|
172
|
+
r"i\s+apologi[sz]e\b.*"
|
|
173
|
+
r"|i'?m\s+sorry\b.*"
|
|
174
|
+
r"|it\s+seems\s+that\s+i\b.*"
|
|
175
|
+
r"|sure[,!.]?\s*(?:thing)?\b.*"
|
|
176
|
+
r"|certainly[,!.]?\b.*"
|
|
177
|
+
r"|of\s+course[,!.]?\b.*"
|
|
178
|
+
r"|here(?:'s|\s+is|\s+are)\b[^.!?]*:"
|
|
179
|
+
r"|to\s+(?:provide|summari[sz]e|answer)\b[^.!?]*:"
|
|
180
|
+
r"|based\s+on\s+the\s+(?:facts|texts|information|data)\s+provided[,:]?"
|
|
181
|
+
r"|as\s+(?:an|a)\s+(?:ai|language\s+model)\b.*"
|
|
182
|
+
r")\s*$"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
#: Closing pleasantries. Same anchoring rule.
|
|
186
|
+
_LLM_POSTAMBLE = (
|
|
187
|
+
r"^(?:"
|
|
188
|
+
r"(?:i\s+hope|hope)\s+(?:this|that)\s+helps\b.*"
|
|
189
|
+
r"|let\s+me\s+know\b.*"
|
|
190
|
+
r"|feel\s+free\s+to\b.*"
|
|
191
|
+
r"|would\s+you\s+like\s+me\s+to\b.*"
|
|
192
|
+
r")$"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def clean_llm_summary(text: str) -> str:
|
|
197
|
+
"""Strip chat-assistant scaffolding from a model-written summary.
|
|
198
|
+
|
|
199
|
+
WHY THIS EXISTS
|
|
200
|
+
---------------
|
|
201
|
+
Mode B/C summaries are shown to the user as *their* memory, with no chat
|
|
202
|
+
framing around them. A chat-tuned model does not know that: it opens with an
|
|
203
|
+
apology or "Here is a concise summary:" and closes with "Let me know if you
|
|
204
|
+
want more detail". Both are addressed to a conversation that the reader
|
|
205
|
+
cannot see, and both make the product look broken.
|
|
206
|
+
|
|
207
|
+
The system prompt now asks for bare prose, which handles most of it. This is
|
|
208
|
+
the second line of defence, because instruction-following on a 3B local
|
|
209
|
+
model is not something to bet the displayed output on.
|
|
210
|
+
|
|
211
|
+
Conservative by construction: patterns are anchored to whole paragraphs or
|
|
212
|
+
whole leading sentences, so a summary that legitimately contains the word
|
|
213
|
+
"sure" mid-paragraph is untouched. If stripping would empty the text, the
|
|
214
|
+
original is returned — a scaffolded summary beats a blank one.
|
|
215
|
+
"""
|
|
216
|
+
import re
|
|
217
|
+
|
|
218
|
+
original = (text or "").strip()
|
|
219
|
+
if not original:
|
|
220
|
+
return ""
|
|
221
|
+
|
|
222
|
+
# Fenced code blocks wrapping the whole answer: keep the contents.
|
|
223
|
+
fenced = re.match(r"^```[a-zA-Z]*\n(.*?)\n?```$", original, re.DOTALL)
|
|
224
|
+
if fenced:
|
|
225
|
+
original = fenced.group(1).strip()
|
|
226
|
+
|
|
227
|
+
paras = [p.strip() for p in re.split(r"\n\s*\n", original) if p.strip()]
|
|
228
|
+
|
|
229
|
+
while paras and re.match(_LLM_PREAMBLE, paras[0], re.IGNORECASE | re.DOTALL):
|
|
230
|
+
paras.pop(0)
|
|
231
|
+
while paras and re.match(_LLM_POSTAMBLE, paras[-1], re.IGNORECASE | re.DOTALL):
|
|
232
|
+
paras.pop()
|
|
233
|
+
|
|
234
|
+
if not paras:
|
|
235
|
+
return original
|
|
236
|
+
|
|
237
|
+
# A preamble that shares a paragraph with real content: drop just the leading
|
|
238
|
+
# sentence, and only when what follows is substantial enough to stand alone.
|
|
239
|
+
lead = re.match(r"(.+?[.:!?])\s+(\S.*)$", paras[0], re.DOTALL)
|
|
240
|
+
if lead and re.match(_LLM_PREAMBLE, lead.group(1), re.IGNORECASE | re.DOTALL):
|
|
241
|
+
if len(lead.group(2).strip()) > 40:
|
|
242
|
+
paras[0] = lead.group(2).strip()
|
|
243
|
+
|
|
244
|
+
cleaned = "\n\n".join(paras).strip()
|
|
245
|
+
return cleaned or original
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
#: System prompt for every summary generator, Mode B and Mode C alike.
|
|
249
|
+
#:
|
|
250
|
+
#: Mode C always sent one; Mode B sent none at all, which is why local-model
|
|
251
|
+
#: output arrived wrapped in chat scaffolding while cloud output did not.
|
|
252
|
+
SUMMARY_SYSTEM_PROMPT = (
|
|
253
|
+
"You summarise a person's own saved notes for them. "
|
|
254
|
+
"Reply with the summary text only — no preamble, no apologies, no sign-off, "
|
|
255
|
+
"no markdown headings, and never refer to yourself or to these instructions. "
|
|
256
|
+
"Write plain prose in the third person about the work described."
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
101
260
|
def get_mode_str(config: object | None) -> str:
|
|
102
261
|
"""Extract the operating mode string ('a', 'b', or 'c') from a config."""
|
|
103
262
|
if config is None:
|
|
@@ -34,6 +34,9 @@ from datetime import date
|
|
|
34
34
|
from pathlib import Path
|
|
35
35
|
|
|
36
36
|
from .base import (
|
|
37
|
+
clean_llm_summary,
|
|
38
|
+
format_highlight,
|
|
39
|
+
SUMMARY_SYSTEM_PROMPT,
|
|
37
40
|
COVERAGE_FULL,
|
|
38
41
|
COVERAGE_INSUFFICIENT,
|
|
39
42
|
COVERAGE_UNAVAILABLE,
|
|
@@ -128,7 +131,7 @@ def generate_daily_reflection(
|
|
|
128
131
|
coverage = COVERAGE_FULL
|
|
129
132
|
|
|
130
133
|
# ── extractive summary (deterministic, always available) ──────────────────
|
|
131
|
-
extractive_content = _build_extractive_content(date_str, facts, fact_count)
|
|
134
|
+
extractive_content = _build_extractive_content(date_str, facts, fact_count, db_path, profile_id)
|
|
132
135
|
|
|
133
136
|
# ── LLM enrichment (optional) ─────────────────────────────────────────────
|
|
134
137
|
mode = get_mode_str(config)
|
|
@@ -162,8 +165,14 @@ def _build_extractive_content(
|
|
|
162
165
|
date_str: str,
|
|
163
166
|
facts: list[dict],
|
|
164
167
|
fact_count: int,
|
|
168
|
+
db_path: str | Path,
|
|
169
|
+
profile_id: str,
|
|
165
170
|
) -> str:
|
|
166
|
-
"""Build a deterministic extractive daily reflection.
|
|
171
|
+
"""Build a deterministic extractive daily reflection.
|
|
172
|
+
|
|
173
|
+
``db_path`` / ``profile_id`` are needed only to turn entity IDs into names
|
|
174
|
+
for the "Active entities" line — the facts themselves are already loaded.
|
|
175
|
+
"""
|
|
167
176
|
import json
|
|
168
177
|
|
|
169
178
|
lines = [
|
|
@@ -174,8 +183,7 @@ def _build_extractive_content(
|
|
|
174
183
|
]
|
|
175
184
|
for f in facts[:_BODY_FACTS]:
|
|
176
185
|
content = f.get("content", "")
|
|
177
|
-
|
|
178
|
-
content = content[:_MAX_FACT_CHARS - 3] + "..."
|
|
186
|
+
content = format_highlight(content)
|
|
179
187
|
lines.append(f" - {content}")
|
|
180
188
|
if fact_count > _BODY_FACTS:
|
|
181
189
|
lines.append(f" ... and {fact_count - _BODY_FACTS} additional facts.")
|
|
@@ -193,13 +201,50 @@ def _build_extractive_content(
|
|
|
193
201
|
|
|
194
202
|
if entity_counts:
|
|
195
203
|
top_entities = sorted(entity_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
196
|
-
|
|
204
|
+
# Resolve to names. canonical_entities_json stores entity IDs, so this
|
|
205
|
+
# line previously read "Active entities: 1666abc512904473 (127),
|
|
206
|
+
# 84288f2dde994afe (124)" — five 16-hex identifiers, which tell a reader
|
|
207
|
+
# nothing. They resolve to 'Fixed', 'Gateway', 'REVISED' and so on. This
|
|
208
|
+
# is the same defect 4.0.6 fixed in the Living Brain, where source
|
|
209
|
+
# quality listed internal identifiers; it survived here because this
|
|
210
|
+
# generator was never reachable to be looked at.
|
|
211
|
+
names = _entity_names(db_path, [e for e, _ in top_entities], profile_id)
|
|
212
|
+
labelled = [
|
|
213
|
+
(names.get(eid) or eid, count) for eid, count in top_entities
|
|
214
|
+
]
|
|
215
|
+
entity_str = ", ".join(f"{name} ({c})" for name, c in labelled)
|
|
197
216
|
lines.append("")
|
|
198
217
|
lines.append(f"Active entities: {entity_str}")
|
|
199
218
|
|
|
200
219
|
return "\n".join(lines)
|
|
201
220
|
|
|
202
221
|
|
|
222
|
+
def _entity_names(
|
|
223
|
+
db_path: str | Path, entity_ids: list[str], profile_id: str
|
|
224
|
+
) -> dict[str, str]:
|
|
225
|
+
"""Map entity_id → canonical_name. Missing or unreadable rows are omitted.
|
|
226
|
+
|
|
227
|
+
Fail-open: a summary is still useful with a raw id in it, so a query problem
|
|
228
|
+
must not lose the whole line. The caller falls back to the id per entity.
|
|
229
|
+
"""
|
|
230
|
+
if not entity_ids:
|
|
231
|
+
return {}
|
|
232
|
+
try:
|
|
233
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
234
|
+
try:
|
|
235
|
+
placeholders = ",".join("?" for _ in entity_ids)
|
|
236
|
+
rows = conn.execute(
|
|
237
|
+
f"SELECT entity_id, canonical_name FROM canonical_entities "
|
|
238
|
+
f"WHERE entity_id IN ({placeholders}) AND profile_id = ?",
|
|
239
|
+
(*entity_ids, profile_id),
|
|
240
|
+
).fetchall()
|
|
241
|
+
finally:
|
|
242
|
+
conn.close()
|
|
243
|
+
return {r[0]: r[1] for r in rows if r[1]}
|
|
244
|
+
except sqlite3.Error:
|
|
245
|
+
return {}
|
|
246
|
+
|
|
247
|
+
|
|
203
248
|
def _try_llm(
|
|
204
249
|
date_str: str,
|
|
205
250
|
facts: list[dict],
|
|
@@ -249,6 +294,7 @@ def _call_ollama(
|
|
|
249
294
|
payload = json.dumps({
|
|
250
295
|
"model": model,
|
|
251
296
|
"prompt": full_prompt,
|
|
297
|
+
"system": SUMMARY_SYSTEM_PROMPT,
|
|
252
298
|
"stream": False,
|
|
253
299
|
"options": {"num_predict": 300},
|
|
254
300
|
}).encode()
|
|
@@ -259,7 +305,7 @@ def _call_ollama(
|
|
|
259
305
|
)
|
|
260
306
|
resp = urllib.request.urlopen(req, timeout=timeout)
|
|
261
307
|
data = json.loads(resp.read().decode())
|
|
262
|
-
text = data.get("response", "")
|
|
308
|
+
text = clean_llm_summary(data.get("response", ""))
|
|
263
309
|
return text if text and len(text) > 20 else None
|
|
264
310
|
except Exception as exc:
|
|
265
311
|
logger.debug("Ollama daily reflection failed: %s", exc)
|
|
@@ -283,11 +329,12 @@ def _call_cloud_llm(
|
|
|
283
329
|
full_prompt = f"{prompt}\n\nFacts:\n{fact_texts}"
|
|
284
330
|
text = llm.generate(
|
|
285
331
|
prompt=full_prompt,
|
|
286
|
-
system=
|
|
332
|
+
system=SUMMARY_SYSTEM_PROMPT,
|
|
287
333
|
max_tokens=300,
|
|
288
334
|
temperature=0.1,
|
|
289
335
|
)
|
|
290
|
-
|
|
336
|
+
cleaned = clean_llm_summary(text or "")
|
|
337
|
+
return cleaned if len(cleaned) > 20 else None
|
|
291
338
|
except Exception as exc:
|
|
292
339
|
logger.debug("Cloud LLM daily reflection failed: %s", exc)
|
|
293
340
|
return None
|
|
@@ -39,8 +39,12 @@ from collections import Counter
|
|
|
39
39
|
from pathlib import Path
|
|
40
40
|
|
|
41
41
|
from .base import (
|
|
42
|
+
clean_llm_summary,
|
|
43
|
+
format_highlight,
|
|
44
|
+
SUMMARY_SYSTEM_PROMPT,
|
|
42
45
|
COVERAGE_FULL,
|
|
43
46
|
COVERAGE_INSUFFICIENT,
|
|
47
|
+
COVERAGE_PARTIAL,
|
|
44
48
|
COVERAGE_UNAVAILABLE,
|
|
45
49
|
GENERATED_BY_EXTRACTIVE,
|
|
46
50
|
GENERATED_BY_LLM_B,
|
|
@@ -121,8 +125,19 @@ def generate_project_work_log(
|
|
|
121
125
|
event_count = len(tool_rows)
|
|
122
126
|
fact_count = len(facts_rows)
|
|
123
127
|
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
# A project work log has two inputs: what was DONE (tool events) and what was
|
|
129
|
+
# LEARNED (facts). "full" means both were there.
|
|
130
|
+
#
|
|
131
|
+
# The previous rule was `events >= 1 OR facts >= 1`, which reported "full" for
|
|
132
|
+
# a project with 86 tool events and zero facts — rendered in the dashboard as
|
|
133
|
+
# "Built from 0 memories · coverage: full". Claiming full coverage of nothing
|
|
134
|
+
# is precisely the dishonest summary issue #113 exists to prevent.
|
|
135
|
+
if event_count >= _MIN_EVENTS and fact_count >= _MIN_FACTS:
|
|
136
|
+
coverage = COVERAGE_FULL
|
|
137
|
+
elif event_count >= _MIN_EVENTS or fact_count >= _MIN_FACTS:
|
|
138
|
+
coverage = COVERAGE_PARTIAL
|
|
139
|
+
else:
|
|
140
|
+
coverage = COVERAGE_INSUFFICIENT
|
|
126
141
|
|
|
127
142
|
extractive_content = _build_extractive_content(
|
|
128
143
|
project_path, tool_rows, facts_rows, event_count, fact_count
|
|
@@ -302,8 +317,7 @@ def _build_extractive_content(
|
|
|
302
317
|
lines.append("Key facts from project sessions:")
|
|
303
318
|
for f in facts_rows[:_TOP_FACTS]:
|
|
304
319
|
content = f.get("content", "")
|
|
305
|
-
|
|
306
|
-
content = content[:_MAX_FACT_CHARS - 3] + "..."
|
|
320
|
+
content = format_highlight(content)
|
|
307
321
|
lines.append(f" - {content}")
|
|
308
322
|
if fact_count > _TOP_FACTS:
|
|
309
323
|
lines.append(f" ... and {fact_count - _TOP_FACTS} more facts.")
|
|
@@ -373,6 +387,7 @@ def _call_ollama(
|
|
|
373
387
|
payload = json.dumps({
|
|
374
388
|
"model": model,
|
|
375
389
|
"prompt": full_prompt,
|
|
390
|
+
"system": SUMMARY_SYSTEM_PROMPT,
|
|
376
391
|
"stream": False,
|
|
377
392
|
"options": {"num_predict": 300},
|
|
378
393
|
}).encode()
|
|
@@ -383,7 +398,7 @@ def _call_ollama(
|
|
|
383
398
|
)
|
|
384
399
|
resp = urllib.request.urlopen(req, timeout=timeout)
|
|
385
400
|
data = json.loads(resp.read().decode())
|
|
386
|
-
text = data.get("response", "")
|
|
401
|
+
text = clean_llm_summary(data.get("response", ""))
|
|
387
402
|
return text if text and len(text) > 20 else None
|
|
388
403
|
except Exception as exc:
|
|
389
404
|
logger.debug("Ollama project work log failed: %s", exc)
|
|
@@ -414,11 +429,12 @@ def _call_cloud_llm(
|
|
|
414
429
|
)
|
|
415
430
|
text = llm.generate(
|
|
416
431
|
prompt=full_prompt,
|
|
417
|
-
system=
|
|
432
|
+
system=SUMMARY_SYSTEM_PROMPT,
|
|
418
433
|
max_tokens=300,
|
|
419
434
|
temperature=0.1,
|
|
420
435
|
)
|
|
421
|
-
|
|
436
|
+
cleaned = clean_llm_summary(text or "")
|
|
437
|
+
return cleaned if len(cleaned) > 20 else None
|
|
422
438
|
except Exception as exc:
|
|
423
439
|
logger.debug("Cloud LLM project work log failed: %s", exc)
|
|
424
440
|
return None
|
|
@@ -37,6 +37,9 @@ from datetime import date, timezone
|
|
|
37
37
|
from pathlib import Path
|
|
38
38
|
|
|
39
39
|
from .base import (
|
|
40
|
+
clean_llm_summary,
|
|
41
|
+
format_highlight,
|
|
42
|
+
SUMMARY_SYSTEM_PROMPT,
|
|
40
43
|
COVERAGE_FULL,
|
|
41
44
|
COVERAGE_INSUFFICIENT,
|
|
42
45
|
COVERAGE_NO_SESSION,
|
|
@@ -209,8 +212,7 @@ def _build_extractive_content(
|
|
|
209
212
|
]
|
|
210
213
|
for f in facts[:_BODY_FACTS]:
|
|
211
214
|
content = f.get("content", "")
|
|
212
|
-
|
|
213
|
-
content = content[:_MAX_FACT_CHARS - 3] + "..."
|
|
215
|
+
content = format_highlight(content)
|
|
214
216
|
lines.append(f" - {content}")
|
|
215
217
|
if fact_count > _BODY_FACTS:
|
|
216
218
|
lines.append(f" ... and {fact_count - _BODY_FACTS} more facts.")
|
|
@@ -263,6 +265,7 @@ def _call_ollama(
|
|
|
263
265
|
payload = json.dumps({
|
|
264
266
|
"model": model,
|
|
265
267
|
"prompt": full_prompt,
|
|
268
|
+
"system": SUMMARY_SYSTEM_PROMPT,
|
|
266
269
|
"stream": False,
|
|
267
270
|
"options": {"num_predict": 200},
|
|
268
271
|
}).encode()
|
|
@@ -273,7 +276,7 @@ def _call_ollama(
|
|
|
273
276
|
)
|
|
274
277
|
resp = urllib.request.urlopen(req, timeout=timeout)
|
|
275
278
|
data = json.loads(resp.read().decode())
|
|
276
|
-
text = data.get("response", "")
|
|
279
|
+
text = clean_llm_summary(data.get("response", ""))
|
|
277
280
|
return text if text and len(text) > 20 else None
|
|
278
281
|
except Exception as exc:
|
|
279
282
|
logger.debug("Ollama session summary failed: %s", exc)
|
|
@@ -297,11 +300,12 @@ def _call_cloud_llm(
|
|
|
297
300
|
full_prompt = f"{prompt}\n\nFacts:\n{fact_texts}\n\nRespond in 2-4 sentences."
|
|
298
301
|
text = llm.generate(
|
|
299
302
|
prompt=full_prompt,
|
|
300
|
-
system=
|
|
303
|
+
system=SUMMARY_SYSTEM_PROMPT,
|
|
301
304
|
max_tokens=200,
|
|
302
305
|
temperature=0.1,
|
|
303
306
|
)
|
|
304
|
-
|
|
307
|
+
cleaned = clean_llm_summary(text or "")
|
|
308
|
+
return cleaned if len(cleaned) > 20 else None
|
|
305
309
|
except Exception as exc:
|
|
306
310
|
logger.debug("Cloud LLM session summary failed: %s", exc)
|
|
307
311
|
return None
|
|
@@ -1017,6 +1017,11 @@
|
|
|
1017
1017
|
<div id="skill-detail-panel" style="display:none;margin-top:24px"></div>
|
|
1018
1018
|
</div>
|
|
1019
1019
|
|
|
1020
|
+
<!-- Bounded Loops (v4.0.8) — moved out of Governance: it is a
|
|
1021
|
+
separate product SLM optionally observes, i.e. an integration.
|
|
1022
|
+
Rendered entirely by od-boundedloops.js (window.odRenderLoops). -->
|
|
1023
|
+
<div class="tab-pane fade" id="loops-pane"></div>
|
|
1024
|
+
|
|
1020
1025
|
<!-- Mesh Peers (v3.4.3 — Neural Glass) -->
|
|
1021
1026
|
<div class="tab-pane fade" id="mesh-pane">
|
|
1022
1027
|
<div class="ng-content-header">
|
|
@@ -1575,7 +1580,7 @@
|
|
|
1575
1580
|
<script src="static/js/math-health.js"></script>
|
|
1576
1581
|
<script src="static/js/auto-settings.js"></script>
|
|
1577
1582
|
<script src="static/js/ide-status.js"></script>
|
|
1578
|
-
<script src="static/js/fact-detail.js"></script>
|
|
1583
|
+
<script src="static/js/fact-detail.js?v=e9076ee3"></script>
|
|
1579
1584
|
|
|
1580
1585
|
<!-- Neural Glass shell (v3.4.21 restructured) -->
|
|
1581
1586
|
<script src="static/js/ng-health.js?v=345"></script>
|
|
@@ -1599,13 +1604,14 @@
|
|
|
1599
1604
|
<script src="static/js/od-ops-health.js?v=400"></script>
|
|
1600
1605
|
<script src="static/js/od-team.js?v=379"></script>
|
|
1601
1606
|
<script src="static/js/od-graph.js?v=6812bf6c"></script>
|
|
1602
|
-
<script src="static/js/od-memories.js?v=
|
|
1607
|
+
<script src="static/js/od-memories.js?v=022ff653"></script>
|
|
1603
1608
|
<script src="static/js/od-entities.js?v=379"></script>
|
|
1604
1609
|
<!-- Multi-Agent Memory pane (v3.8.0): visualises memory written by multiple agents -->
|
|
1605
1610
|
<script src="static/js/od-agents.js?v=c75ff3c2"></script>
|
|
1606
1611
|
<script src="static/js/od-skills.js?v=379"></script>
|
|
1607
|
-
<script src="static/js/od-
|
|
1608
|
-
<script src="static/js/od-
|
|
1612
|
+
<script src="static/js/od-boundedloops.js?v=b4a014a1"></script>
|
|
1613
|
+
<script src="static/js/od-mesh.js?v=a7bc694a"></script>
|
|
1614
|
+
<script src="static/js/od-optimize.js?v=1f511698"></script>
|
|
1609
1615
|
<script src="static/js/od-settings.js?v=386"></script>
|
|
1610
1616
|
<script src="static/js/od-backup.js?v=379"></script>
|
|
1611
1617
|
<!-- MCP & Integrations pane (v3.8.0): shows exposed MCP tool profile + counts -->
|