lisa-pack-memory 0.1.0__py3-none-any.whl
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.
- lisa_pack_memory-0.1.0.dist-info/METADATA +9 -0
- lisa_pack_memory-0.1.0.dist-info/RECORD +23 -0
- lisa_pack_memory-0.1.0.dist-info/WHEEL +4 -0
- lisa_pack_memory-0.1.0.dist-info/entry_points.txt +5 -0
- sdk/packs/memory/__init__.py +14 -0
- sdk/packs/memory/mcp_servers.yaml +52 -0
- sdk/packs/memory/operations/memory/__init__.py +1 -0
- sdk/packs/memory/operations/memory/consolidate_semantic.py +375 -0
- sdk/packs/memory/operations/memory/consolidate_semantic.yml +9 -0
- sdk/packs/memory/operations/memory/dispute.py +263 -0
- sdk/packs/memory/operations/memory/dispute.yml +10 -0
- sdk/packs/memory/operations/memory/mine_thread_segment.py +381 -0
- sdk/packs/memory/operations/memory/mine_thread_segment.yml +9 -0
- sdk/packs/memory/operations/memory/prompts/consolidate_semantic_system.md +10 -0
- sdk/packs/memory/operations/memory/prompts/mine_thread_segment_system.md +26 -0
- sdk/packs/memory/operations/memory/prompts/synthesize_learning_system.md +18 -0
- sdk/packs/memory/operations/memory/recall.py +210 -0
- sdk/packs/memory/operations/memory/recall.yml +10 -0
- sdk/packs/memory/operations/memory/synthesize_learning.py +430 -0
- sdk/packs/memory/operations/memory/synthesize_learning.yml +13 -0
- sdk/packs/memory/slice_provider.py +715 -0
- sdk/packs/memory/utils/__init__.py +1 -0
- sdk/packs/memory/utils/memory_recall.py +140 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lisa-pack-memory
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lisa memory pack - memory-backed default and remember workflows
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
8
|
+
Requires-Dist: pytest-mock>=3.14.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
sdk/packs/memory/__init__.py,sha256=TtdMFgU4p4gOJdqpRt3pRBM4NUDnHmJMHk_SG3M1S88,346
|
|
2
|
+
sdk/packs/memory/mcp_servers.yaml,sha256=a_QfEMVaTw8rDCtmPl1NmadsWItHVObRhl6kGwM34sM,1844
|
|
3
|
+
sdk/packs/memory/slice_provider.py,sha256=_Plh6QqL_7-MLc-dDS8P1ONvzgMBmURlWV1Vz37Awkg,28783
|
|
4
|
+
sdk/packs/memory/operations/memory/__init__.py,sha256=praaACA8iuexxKnRWEE6GOUaASViRjTLh6dSrxHgD28,45
|
|
5
|
+
sdk/packs/memory/operations/memory/consolidate_semantic.py,sha256=8UABlnR45hk_jVzV0ZWAoTmzMSrW_v2GG-equnQpNHw,14827
|
|
6
|
+
sdk/packs/memory/operations/memory/consolidate_semantic.yml,sha256=SqRxXcM_w4EL8MWfGluSVdjxhRmiv-Tx3enXJtxAJ9w,466
|
|
7
|
+
sdk/packs/memory/operations/memory/dispute.py,sha256=2Im3_Ua3Q8vm3gq3hkF0NW1gw-n13FeuNMcig--aTDk,9802
|
|
8
|
+
sdk/packs/memory/operations/memory/dispute.yml,sha256=bmrhJ3KQ3LAS7q25HFL26cJgwxJltbgYuIlko-nW0jE,447
|
|
9
|
+
sdk/packs/memory/operations/memory/mine_thread_segment.py,sha256=0pCGzXJd3UY5y2rzcCWrgaKGpXZdy6q9gYAvCXh2xq4,14321
|
|
10
|
+
sdk/packs/memory/operations/memory/mine_thread_segment.yml,sha256=wCj7VEyuw5eW3d28qYHcHgbNk50x_E4Vnf7wrXVGo3c,460
|
|
11
|
+
sdk/packs/memory/operations/memory/recall.py,sha256=LDfv5XBf1Xu0PnDhN198ttJtqlNKHJtg172TPGNJBJw,8114
|
|
12
|
+
sdk/packs/memory/operations/memory/recall.yml,sha256=3T92QMncorh4SZ1RI6BQf4qCs_D2dUao33f3mxETy1E,403
|
|
13
|
+
sdk/packs/memory/operations/memory/synthesize_learning.py,sha256=1KSwwVoFvN50x6MTqiqZH90CZ4W56b1lG2Ira2f7zno,17586
|
|
14
|
+
sdk/packs/memory/operations/memory/synthesize_learning.yml,sha256=G25y9WFNYMtqr3dE3sJKY6dyQDrvWfGb5Yc2E-Xy2WE,651
|
|
15
|
+
sdk/packs/memory/operations/memory/prompts/consolidate_semantic_system.md,sha256=5yvK9QkxvNcNtLZsAGrAffLwVWDSxvLYIejAuMfliAg,583
|
|
16
|
+
sdk/packs/memory/operations/memory/prompts/mine_thread_segment_system.md,sha256=pvSi3h0RScNeUr8jlcQhu3nYiYyLxEn_54EkefDs1f8,1314
|
|
17
|
+
sdk/packs/memory/operations/memory/prompts/synthesize_learning_system.md,sha256=7ANs_khJZDGL7gUK-YbCyak0yHOa2H4ChOVLVnhCuxE,953
|
|
18
|
+
sdk/packs/memory/utils/__init__.py,sha256=FOcfWAhMeGcMxXol95__Dw8-NEI5KBA_IkaIP80XPXo,37
|
|
19
|
+
sdk/packs/memory/utils/memory_recall.py,sha256=y76OvLFFQJlkfI7JxFBrRAfE21szdZ7l7FnYA6JIhV0,5850
|
|
20
|
+
lisa_pack_memory-0.1.0.dist-info/METADATA,sha256=LjJIiJTYr8zFYLifmNzFJ23CCe7K-GtQX6EIbU0COWA,327
|
|
21
|
+
lisa_pack_memory-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
22
|
+
lisa_pack_memory-0.1.0.dist-info/entry_points.txt,sha256=jgizxe6Zl4rgyOy0CDmwt9N3lqHMz9NrxV-GHs0i-cE,192
|
|
23
|
+
lisa_pack_memory-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Memory pack for Lisa.
|
|
2
|
+
|
|
3
|
+
Adds memory-backed conversational behavior and explicit remember-mode workflows
|
|
4
|
+
without requiring changes to core-pack content.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_content_root() -> Path:
|
|
13
|
+
"""Return the content root directory for this pack."""
|
|
14
|
+
return Path(__file__).parent
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
version: 1
|
|
3
|
+
|
|
4
|
+
# Memory provides the bundled memory fallback (lisa_memory).
|
|
5
|
+
# External providers (such as Mem0) are contributed by separate packs and can
|
|
6
|
+
# replace this default through host provider-selection policy.
|
|
7
|
+
memory:
|
|
8
|
+
default_backend: lisa_memory
|
|
9
|
+
backends:
|
|
10
|
+
- id: lisa_memory
|
|
11
|
+
kind: stdio
|
|
12
|
+
command: "${LISA_MCP_COMMAND:lisa-memory}"
|
|
13
|
+
args:
|
|
14
|
+
- --data-dir
|
|
15
|
+
- "${LISA_DATA_DIR}"
|
|
16
|
+
tool_names:
|
|
17
|
+
add: add_memory
|
|
18
|
+
search: search_memory
|
|
19
|
+
list: list_memories
|
|
20
|
+
get: get_memory
|
|
21
|
+
delete: delete_memory
|
|
22
|
+
retire: retire_memory
|
|
23
|
+
count: count_memories
|
|
24
|
+
servers:
|
|
25
|
+
- server_id: lisa-memory
|
|
26
|
+
runtime_kind: venv_bin
|
|
27
|
+
entry_point: lisa-memory
|
|
28
|
+
venv_dir: "${FRAMEWORK_ROOT}"
|
|
29
|
+
warm: true
|
|
30
|
+
required: true
|
|
31
|
+
# tool_policy hides raw native tools from the model-facing catalog.
|
|
32
|
+
# The model calls the generic aliases (memory.add / memory.search /
|
|
33
|
+
# memory.list) instead. Internal dispatch (alias forwarding,
|
|
34
|
+
# operations) still reaches these tools — the per-server index is
|
|
35
|
+
# NOT filtered, only the model catalog is.
|
|
36
|
+
tool_policy:
|
|
37
|
+
denied_tools:
|
|
38
|
+
- get_memory
|
|
39
|
+
- delete_memory
|
|
40
|
+
- list_memories
|
|
41
|
+
- retire_memory
|
|
42
|
+
- count_memories
|
|
43
|
+
# proxy_policy strips the ambient identity field (user_slug) from the
|
|
44
|
+
# native tools' model-facing schema and injects it from the host
|
|
45
|
+
# runtime at dispatch time. The model never sees or sets user_slug —
|
|
46
|
+
# the ambient resolver registered by register_memory_aliases supplies
|
|
47
|
+
# the active user. Without this, the native add_memory / search_memory
|
|
48
|
+
# tools would surface user_slug as a settable parameter and the model
|
|
49
|
+
# would be forced to invent one (it has no source of truth).
|
|
50
|
+
proxy_policy:
|
|
51
|
+
ambient:
|
|
52
|
+
user_slug: user_slug
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Memory operations for the memory pack."""
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Consolidate a cluster of episodic memories into one durable semantic record.
|
|
2
|
+
|
|
3
|
+
The memory-consolidation slice (``sdk/packs/memory/slice_provider.py``)
|
|
4
|
+
assembles a batch of related episodic records and invokes this operation
|
|
5
|
+
via ``turn_hooks.execute_operation``. This operation distills the batch
|
|
6
|
+
into a single self-contained semantic fact using the same structured-output
|
|
7
|
+
loop as ``synthesize_learning``, writes the semantic record via
|
|
8
|
+
``memory.add`` with ``memory_kind="semantic"``, a boosted salience, and
|
|
9
|
+
``supersedes_id``/``item_ids`` linking it to its sources, then retires
|
|
10
|
+
each episodic source via ``memory.retire`` so the default
|
|
11
|
+
``status="active"`` recall pool stops surfacing the originals.
|
|
12
|
+
|
|
13
|
+
Reuses the ``synthesize_learning`` distillation seam (``ctx.host.complete``
|
|
14
|
+
+ ``json_only_retry_messages`` + ``parse_structured_payload``) and the
|
|
15
|
+
authored-prompt loader (no inline fallbacks per the pack-content rule).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Any, cast
|
|
23
|
+
|
|
24
|
+
from kernel.models import OperationExecutionContext, OperationResult, ToolExecutionContext
|
|
25
|
+
from kernel.operations.structured_output import (
|
|
26
|
+
completion_content,
|
|
27
|
+
json_only_retry_messages,
|
|
28
|
+
parse_structured_payload,
|
|
29
|
+
)
|
|
30
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
31
|
+
from sdk.content import require_authored_text
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Authored-text path — loaded via ctx.host.read_authored_text (SDK loader).
|
|
36
|
+
_SYSTEM_PROMPT_PATH = "operations/memory/prompts/consolidate_semantic_system.md"
|
|
37
|
+
|
|
38
|
+
# Fallback default for the consolidation LLM token budget. The authoritative
|
|
39
|
+
# source is the session manifest's
|
|
40
|
+
# ``resources.packs.configuration.lisa-pack-memory`` block, surfaced to
|
|
41
|
+
# the op at runtime as ``ctx.pack_config``.
|
|
42
|
+
_CONSOLIDATE_MAX_TOKENS = 768
|
|
43
|
+
|
|
44
|
+
#: Max chars of the summary shown in the user-facing output preview.
|
|
45
|
+
_PREVIEW_CHARS = 80
|
|
46
|
+
#: Max retired ids shown inline in the output before switching to "+N more".
|
|
47
|
+
_PREVIEW_IDS = 8
|
|
48
|
+
#: Salience floor and boost for the consolidated semantic record.
|
|
49
|
+
_SEMANTIC_SALIENCE_FLOOR = 0.8
|
|
50
|
+
_SEMANTIC_SALIENCE_BOOST = 0.1
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class _ResolvedSettings:
|
|
55
|
+
"""Resolved operation budgets: ``ctx.pack_config`` overrides module fallbacks."""
|
|
56
|
+
|
|
57
|
+
consolidate_max_tokens: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cfg_int(pack_config: Any, key: str, default: int) -> int:
|
|
61
|
+
"""Read an int from ``ctx.pack_config`` with a module-constant fallback."""
|
|
62
|
+
if pack_config is None:
|
|
63
|
+
return default
|
|
64
|
+
raw = pack_config.get(key)
|
|
65
|
+
if raw is None or raw == "":
|
|
66
|
+
return default
|
|
67
|
+
try:
|
|
68
|
+
return int(raw)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
logger.warning(
|
|
71
|
+
"consolidate_semantic: pack_config[%r]=%r is not an int; using default %d",
|
|
72
|
+
key,
|
|
73
|
+
raw,
|
|
74
|
+
default,
|
|
75
|
+
)
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_settings(ctx: OperationExecutionContext) -> _ResolvedSettings:
|
|
80
|
+
"""Resolve operation budgets from ``ctx.pack_config`` with fallbacks."""
|
|
81
|
+
return _ResolvedSettings(
|
|
82
|
+
consolidate_max_tokens=_cfg_int(
|
|
83
|
+
ctx.pack_config, "memory_consolidate_max_tokens", _CONSOLIDATE_MAX_TOKENS
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _load_system_prompt(ctx: OperationExecutionContext) -> str:
|
|
89
|
+
"""Load the consolidation system prompt via the SDK content-registry loader.
|
|
90
|
+
|
|
91
|
+
Fails loudly when the loader is not wired, returns empty/None, or raises.
|
|
92
|
+
Per the pack-content rule: no fallbacks to missing content.
|
|
93
|
+
"""
|
|
94
|
+
return require_authored_text(
|
|
95
|
+
ctx.host.read_authored_text if ctx.host is not None else None,
|
|
96
|
+
_SYSTEM_PROMPT_PATH,
|
|
97
|
+
context="consolidate_semantic operation",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Parameters(BaseModel):
|
|
102
|
+
"""User-/slice-supplied inputs for the consolidation operation."""
|
|
103
|
+
|
|
104
|
+
episodic_records: list[dict[str, Any]] = Field(
|
|
105
|
+
...,
|
|
106
|
+
description="The episodic memory records to consolidate, each with "
|
|
107
|
+
"at least id, content, and created_at.",
|
|
108
|
+
min_length=1,
|
|
109
|
+
)
|
|
110
|
+
memory_scope: str = Field("personal", description="Privacy scope for the semantic record.")
|
|
111
|
+
surface_id: str = Field("", description="Surface isolation key.")
|
|
112
|
+
user_slug: str = Field("default", description="The user identity to scope to.")
|
|
113
|
+
workspace_id: str = Field("default", description="Workspace isolation key.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ConsolidatedSemantic(BaseModel):
|
|
117
|
+
"""Strict bounded output from the consolidation LLM call."""
|
|
118
|
+
|
|
119
|
+
summary: str = Field(
|
|
120
|
+
...,
|
|
121
|
+
description="One concise, self-contained semantic sentence preserving "
|
|
122
|
+
"the durable knowledge across the cluster.",
|
|
123
|
+
min_length=1,
|
|
124
|
+
max_length=500,
|
|
125
|
+
)
|
|
126
|
+
confidence: float = Field(
|
|
127
|
+
0.8,
|
|
128
|
+
description="Confidence in the consolidated summary (0.0-1.0).",
|
|
129
|
+
ge=0.0,
|
|
130
|
+
le=1.0,
|
|
131
|
+
)
|
|
132
|
+
item_ids: list[int] = Field(
|
|
133
|
+
...,
|
|
134
|
+
description="The input record ids the summary was derived from.",
|
|
135
|
+
min_length=1,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _render_batch(records: list[dict[str, Any]]) -> str:
|
|
140
|
+
"""Render the episodic batch as a numbered list for the LLM user message."""
|
|
141
|
+
lines: list[str] = []
|
|
142
|
+
for idx, rec in enumerate(records, start=1):
|
|
143
|
+
rid = rec.get("id", "?")
|
|
144
|
+
content = str(rec.get("content", "")).strip()
|
|
145
|
+
created = str(rec.get("created_at", "")).strip()
|
|
146
|
+
lines.append(f"{idx}. [id={rid}, created={created}] {content}")
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parse_consolidated(content: str) -> ConsolidatedSemantic:
|
|
151
|
+
"""Parse structured output with a guard against JSON-fragment fallbacks."""
|
|
152
|
+
normalized = (content or "").strip()
|
|
153
|
+
if not normalized:
|
|
154
|
+
raise ValueError("empty consolidation output")
|
|
155
|
+
try:
|
|
156
|
+
return cast(
|
|
157
|
+
ConsolidatedSemantic, parse_structured_payload(ConsolidatedSemantic, normalized)
|
|
158
|
+
)
|
|
159
|
+
except ValidationError as exc:
|
|
160
|
+
# Mirror synthesize_learning's guard: if the model emitted JSON that
|
|
161
|
+
# failed validation, refuse to fall back to prose parsing — surface
|
|
162
|
+
# the failure instead of persisting garbage.
|
|
163
|
+
if _looks_like_json(normalized):
|
|
164
|
+
raise RuntimeError(
|
|
165
|
+
"consolidate_semantic: model returned JSON-shaped output that "
|
|
166
|
+
f"failed validation; refusing to fall back. Raw: {normalized[:200]!r}"
|
|
167
|
+
) from exc
|
|
168
|
+
raise
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _looks_like_json(text: str) -> bool:
|
|
172
|
+
"""Return True when the response looks like JSON (fenced or bare object)."""
|
|
173
|
+
stripped = text.strip()
|
|
174
|
+
if not stripped:
|
|
175
|
+
return False
|
|
176
|
+
if stripped.startswith("```") or stripped.startswith("{") or stripped.startswith("["):
|
|
177
|
+
return True
|
|
178
|
+
return bool("{" in stripped and "}" in stripped)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# pylint: disable=protected-access — reusing the cast helper pattern from synthesize_learning
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _distill_batch(
|
|
185
|
+
ctx: OperationExecutionContext, system_prompt: str, batch_text: str, max_tokens: int
|
|
186
|
+
) -> ConsolidatedSemantic:
|
|
187
|
+
"""Run the 2-attempt LLM distillation loop over the episodic batch."""
|
|
188
|
+
model_id = ctx.preferred_model_id
|
|
189
|
+
model_def = ctx.host.resolve_model(model_id) if ctx.host is not None else None
|
|
190
|
+
if model_def is None and ctx.host is not None:
|
|
191
|
+
model_def = ctx.host.resolve_model("default")
|
|
192
|
+
if model_def is None:
|
|
193
|
+
raise RuntimeError("consolidate_semantic could not resolve a model")
|
|
194
|
+
first_messages: list[dict[str, Any]] = [
|
|
195
|
+
{"role": "system", "content": system_prompt},
|
|
196
|
+
{"role": "user", "content": batch_text},
|
|
197
|
+
]
|
|
198
|
+
last_exc: Exception | None = None
|
|
199
|
+
for attempt_idx, attempt_messages in enumerate(
|
|
200
|
+
(first_messages, json_only_retry_messages(first_messages, ConsolidatedSemantic))
|
|
201
|
+
):
|
|
202
|
+
try:
|
|
203
|
+
raw = await ctx.host.complete( # type: ignore[union-attr]
|
|
204
|
+
messages=attempt_messages,
|
|
205
|
+
model_def=model_def,
|
|
206
|
+
max_tokens=max_tokens,
|
|
207
|
+
temperature=0.1,
|
|
208
|
+
response_format=ConsolidatedSemantic if attempt_idx == 0 else None,
|
|
209
|
+
)
|
|
210
|
+
return _parse_consolidated(completion_content(raw))
|
|
211
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
212
|
+
last_exc = exc
|
|
213
|
+
if attempt_idx == 0:
|
|
214
|
+
continue
|
|
215
|
+
break
|
|
216
|
+
raise RuntimeError(f"consolidate_semantic distillation failed: {last_exc}") from last_exc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def _persist_semantic(
|
|
220
|
+
ctx: OperationExecutionContext,
|
|
221
|
+
parsed: Parameters,
|
|
222
|
+
consolidated: ConsolidatedSemantic,
|
|
223
|
+
item_ids: list[int],
|
|
224
|
+
source_count: int,
|
|
225
|
+
) -> int | str | None:
|
|
226
|
+
"""Write the consolidated semantic record via ``memory.add`` and return its id."""
|
|
227
|
+
assert ctx.host is not None
|
|
228
|
+
max_source_salience = max(
|
|
229
|
+
(float(rec.get("salience", 0.0)) for rec in parsed.episodic_records), default=0.0
|
|
230
|
+
)
|
|
231
|
+
boosted_salience = max(
|
|
232
|
+
_SEMANTIC_SALIENCE_FLOOR, min(1.0, max_source_salience + _SEMANTIC_SALIENCE_BOOST)
|
|
233
|
+
)
|
|
234
|
+
supersedes_id = item_ids[0] if item_ids else None
|
|
235
|
+
add_result = await ctx.host.execute_tool(
|
|
236
|
+
"memory.add",
|
|
237
|
+
ToolExecutionContext(
|
|
238
|
+
tool_id="memory.add",
|
|
239
|
+
workspace_id=parsed.workspace_id,
|
|
240
|
+
user_id=ctx.user_id,
|
|
241
|
+
user_slug=parsed.user_slug,
|
|
242
|
+
surface_id=parsed.surface_id,
|
|
243
|
+
memory_scope=parsed.memory_scope, # type: ignore[arg-type]
|
|
244
|
+
mode_id=ctx.mode_id,
|
|
245
|
+
parameters={
|
|
246
|
+
"content": consolidated.summary,
|
|
247
|
+
"user_slug": parsed.user_slug,
|
|
248
|
+
"workspace_id": parsed.workspace_id,
|
|
249
|
+
"surface_id": parsed.surface_id,
|
|
250
|
+
"memory_scope": parsed.memory_scope,
|
|
251
|
+
"memory_tier": "long_term",
|
|
252
|
+
"memory_kind": "semantic",
|
|
253
|
+
"operation_id": "consolidate_semantic",
|
|
254
|
+
"salience": boosted_salience,
|
|
255
|
+
"supersedes_id": supersedes_id,
|
|
256
|
+
"item_ids": item_ids,
|
|
257
|
+
"confidence": consolidated.confidence,
|
|
258
|
+
"content_json": {
|
|
259
|
+
"consolidated_from": item_ids,
|
|
260
|
+
"source_count": source_count,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
if getattr(add_result, "error", None):
|
|
266
|
+
raise RuntimeError(f"semantic record storage failed: {add_result.error}")
|
|
267
|
+
add_text = getattr(add_result, "output_text", "") or ""
|
|
268
|
+
if "ID " in add_text:
|
|
269
|
+
try:
|
|
270
|
+
return int(add_text.split("ID ", 1)[1].rstrip("."))
|
|
271
|
+
except (ValueError, IndexError):
|
|
272
|
+
return None
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def _retire_sources(
|
|
277
|
+
ctx: OperationExecutionContext, parsed: Parameters, item_ids: list[int]
|
|
278
|
+
) -> list[int]:
|
|
279
|
+
"""Retire each episodic source via ``memory.retire``; return the ids retired."""
|
|
280
|
+
assert ctx.host is not None
|
|
281
|
+
retired_ids: list[int] = []
|
|
282
|
+
for eid in item_ids:
|
|
283
|
+
retire_result = await ctx.host.execute_tool(
|
|
284
|
+
"memory.retire",
|
|
285
|
+
ToolExecutionContext(
|
|
286
|
+
tool_id="memory.retire",
|
|
287
|
+
workspace_id=parsed.workspace_id,
|
|
288
|
+
user_id=ctx.user_id,
|
|
289
|
+
user_slug=parsed.user_slug,
|
|
290
|
+
surface_id=parsed.surface_id,
|
|
291
|
+
memory_scope=parsed.memory_scope,
|
|
292
|
+
mode_id=ctx.mode_id,
|
|
293
|
+
parameters={
|
|
294
|
+
"memory_id": eid,
|
|
295
|
+
"user_slug": parsed.user_slug,
|
|
296
|
+
"workspace_id": parsed.workspace_id,
|
|
297
|
+
"surface_id": parsed.surface_id,
|
|
298
|
+
"memory_scope": parsed.memory_scope,
|
|
299
|
+
},
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
if not getattr(retire_result, "error", None):
|
|
303
|
+
retired_ids.append(eid)
|
|
304
|
+
return retired_ids
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def execute(ctx: OperationExecutionContext) -> OperationResult:
|
|
308
|
+
"""Distill an episodic batch into one semantic record and retire the sources."""
|
|
309
|
+
params = ctx.params or {}
|
|
310
|
+
try:
|
|
311
|
+
parsed = Parameters.model_validate(params)
|
|
312
|
+
except ValidationError as exc:
|
|
313
|
+
return OperationResult(output_text=f"⛔ Invalid consolidation parameters: {exc}")
|
|
314
|
+
|
|
315
|
+
records = parsed.episodic_records
|
|
316
|
+
if not records:
|
|
317
|
+
return OperationResult(output_text="⛔ No episodic records provided to consolidate.")
|
|
318
|
+
|
|
319
|
+
if ctx.host is None:
|
|
320
|
+
raise RuntimeError("consolidate_semantic requires the OperationHost seam")
|
|
321
|
+
|
|
322
|
+
# Step 1: Distill the batch with the LLM (delegates to _distill_batch).
|
|
323
|
+
system_prompt = _load_system_prompt(ctx)
|
|
324
|
+
batch_text = _render_batch(records)
|
|
325
|
+
consolidate_max_tokens = _resolve_settings(ctx).consolidate_max_tokens
|
|
326
|
+
try:
|
|
327
|
+
consolidated = await _distill_batch(ctx, system_prompt, batch_text, consolidate_max_tokens)
|
|
328
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
329
|
+
raise RuntimeError(f"consolidate_semantic distillation failed: {exc}") from exc
|
|
330
|
+
|
|
331
|
+
# Validate item_ids are a subset of the input ids (guard against
|
|
332
|
+
# the model hallucinating ids that were not in the batch).
|
|
333
|
+
input_ids = {int(rec.get("id")) for rec in records if rec.get("id") is not None}
|
|
334
|
+
item_ids = [eid for eid in consolidated.item_ids if eid in input_ids]
|
|
335
|
+
if not item_ids:
|
|
336
|
+
# Fall back to the full input set if the model returned no valid ids.
|
|
337
|
+
item_ids = sorted(input_ids)
|
|
338
|
+
|
|
339
|
+
# Step 2: Persist the semantic record via memory.add with boosted salience.
|
|
340
|
+
try:
|
|
341
|
+
semantic_id = await _persist_semantic(ctx, parsed, consolidated, item_ids, len(records))
|
|
342
|
+
except RuntimeError as exc:
|
|
343
|
+
return OperationResult(output_text=f"⛔ {exc}")
|
|
344
|
+
|
|
345
|
+
# Step 3: Retire the episodic sources via memory.retire so the default
|
|
346
|
+
# status='active' recall pool stops surfacing them. The originals stay
|
|
347
|
+
# queryable with status=None (audit trail preserved).
|
|
348
|
+
retired_ids = await _retire_sources(ctx, parsed, item_ids)
|
|
349
|
+
|
|
350
|
+
preview = consolidated.summary[:_PREVIEW_CHARS]
|
|
351
|
+
return OperationResult(
|
|
352
|
+
output_text=(
|
|
353
|
+
f"✅ Consolidated {len(records)} episodic records into one semantic "
|
|
354
|
+
f"record (id={semantic_id}); retired {len(retired_ids)} sources: "
|
|
355
|
+
f"{_retired_ids_preview(retired_ids)}"
|
|
356
|
+
f"'{preview}{'...' if len(consolidated.summary) > _PREVIEW_CHARS else ''}'"
|
|
357
|
+
),
|
|
358
|
+
extra={
|
|
359
|
+
"consolidated": True,
|
|
360
|
+
"semantic_id": semantic_id,
|
|
361
|
+
"retired_ids": retired_ids,
|
|
362
|
+
"item_ids": item_ids,
|
|
363
|
+
"summary": consolidated.summary,
|
|
364
|
+
"confidence": consolidated.confidence,
|
|
365
|
+
},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _retired_ids_preview(ids: list[int]) -> str:
|
|
370
|
+
"""Format the retired-ids list for the user-facing output line."""
|
|
371
|
+
if not ids:
|
|
372
|
+
return ""
|
|
373
|
+
shown = ", ".join(str(i) for i in ids[:_PREVIEW_IDS])
|
|
374
|
+
more = f", +{len(ids) - _PREVIEW_IDS} more" if len(ids) > _PREVIEW_IDS else ""
|
|
375
|
+
return f"[{shown}{more}]. "
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type: Lisa Operation
|
|
2
|
+
title: Consolidate Episodic Memories into Semantic
|
|
3
|
+
description: Cluster related episodic memory records and distill them into a single durable semantic fact, then retire the originals. Invoked by the memory-consolidation slice; not user-facing by default.
|
|
4
|
+
tags: [memory, consolidation, semantic, reflection]
|
|
5
|
+
status: stable
|
|
6
|
+
generated: { by: human:adam, at: 2026-08-06T00:00:00Z }
|
|
7
|
+
operation_id: consolidate_semantic
|
|
8
|
+
exportable: true
|
|
9
|
+
effect: write
|