Skip to content

Commit 7245e05

Browse files
yeyitechclaude
andcommitted
feat(session): persist metadata + inject into memory extraction prompt (#2414)
Sessions can now carry an arbitrary metadata dict (project name, tech-stack preferences, architectural style, etc.) that survives through commit and is included in the system context the memory extractor sees. Today the only way to express project-level personalization was to allocate a separate agent_id per project; with this change, a single agent can keep distinct memory layers across projects via session metadata. - Session model + storage gain a metadata field (dict, JSON-serializable, size-capped at 16 KB, key-count-capped at 64). - API: POST /api/v1/sessions accepts metadata; PATCH /api/v1/sessions/{id}/metadata merges by default, replace=true overrides. - Memory extractor: when metadata is present, a [Session metadata] block is added to the prompt's system context. - CLI: ov session set-metadata --key K --value V. Closes #2414 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c6990e4 commit 7245e05

10 files changed

Lines changed: 520 additions & 6 deletions

File tree

crates/ov_cli/src/commands/session.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,39 @@ pub async fn commit_session(
364364
Ok(())
365365
}
366366

367+
/// Update (merge) free-form session metadata.
368+
///
369+
/// ``key_values`` is a list of ``key=value`` pairs. Pass ``replace=true`` to
370+
/// overwrite the dict entirely instead of merging.
371+
pub async fn set_session_metadata(
372+
client: &HttpClient,
373+
session_id: &str,
374+
key_values: &[(String, String)],
375+
replace: bool,
376+
output_format: OutputFormat,
377+
compact: bool,
378+
) -> Result<()> {
379+
if key_values.is_empty() {
380+
return Err(Error::Client(
381+
"set-metadata requires at least one --key/--value pair".to_string(),
382+
));
383+
}
384+
let mut metadata = serde_json::Map::new();
385+
for (key, value) in key_values {
386+
metadata.insert(key.clone(), Value::String(value.clone()));
387+
}
388+
let path = format!("/api/v1/sessions/{}/metadata", url_encode(session_id));
389+
let body = json!({"metadata": Value::Object(metadata)});
390+
let params: Vec<(String, String)> = if replace {
391+
vec![("replace".to_string(), "true".to_string())]
392+
} else {
393+
Vec::new()
394+
};
395+
let response: serde_json::Value = client.patch(&path, &body, &params).await?;
396+
output_success(&response, output_format, compact);
397+
Ok(())
398+
}
399+
367400
/// Add memory in one shot: creates a session, adds messages, and commits.
368401
///
369402
/// Input can be:

crates/ov_cli/src/handlers.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,31 @@ pub async fn handle_session(cmd: SessionCommands, ctx: CliContext) -> Result<()>
493493
commands::session::commit_session(&client, &session_id, ctx.output_format, ctx.compact)
494494
.await
495495
}
496+
SessionCommands::SetMetadata {
497+
session_id,
498+
keys,
499+
values,
500+
replace,
501+
} => {
502+
if keys.len() != values.len() {
503+
return Err(crate::error::Error::Client(format!(
504+
"set-metadata requires the same number of --key and --value flags (got {} and {})",
505+
keys.len(),
506+
values.len(),
507+
)));
508+
}
509+
let pairs: Vec<(String, String)> =
510+
keys.into_iter().zip(values.into_iter()).collect();
511+
commands::session::set_session_metadata(
512+
&client,
513+
&session_id,
514+
&pairs,
515+
replace,
516+
ctx.output_format,
517+
ctx.compact,
518+
)
519+
.await
520+
}
496521
}
497522
}
498523

crates/ov_cli/src/main.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,21 @@ enum SessionCommands {
11231123
#[arg(value_name = "session-id")]
11241124
session_id: String,
11251125
},
1126+
/// Merge free-form session metadata (or replace it)
1127+
SetMetadata {
1128+
/// Session ID
1129+
#[arg(value_name = "session-id")]
1130+
session_id: String,
1131+
/// Metadata key (repeatable, paired positionally with --value)
1132+
#[arg(long = "key", value_name = "key", num_args = 1..)]
1133+
keys: Vec<String>,
1134+
/// Metadata value (repeatable, paired positionally with --key)
1135+
#[arg(long = "value", value_name = "value", num_args = 1..)]
1136+
values: Vec<String>,
1137+
/// Replace existing metadata entirely instead of merging
1138+
#[arg(long)]
1139+
replace: bool,
1140+
},
11261141
}
11271142

11281143
#[derive(Subcommand)]

openviking/server/routers/sessions.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ class CreateSessionRequest(BaseModel):
123123

124124
session_id: Optional[str] = None
125125
memory_policy: Optional[Dict[str, Any]] = None
126+
metadata: Optional[Dict[str, Any]] = None
127+
telemetry: TelemetryRequest = False
128+
129+
130+
class UpdateMetadataRequest(BaseModel):
131+
"""Request model for updating session metadata."""
132+
133+
metadata: Dict[str, Any] = Field(default_factory=dict)
126134
telemetry: TelemetryRequest = False
127135

128136

@@ -188,7 +196,12 @@ async def create_session(
188196
189197
If session_id is provided, creates a session with the given ID.
190198
If session_id is None, creates a new session with auto-generated ID.
199+
Optional ``metadata`` carries free-form per-session personalization
200+
(project name, tech-stack preferences, etc.) that is later injected into
201+
the memory extractor's prompt.
191202
"""
203+
from openviking.session.session_metadata import MetadataValidationError
204+
192205
service = get_service()
193206

194207
async def _create() -> dict[str, Any]:
@@ -197,18 +210,23 @@ async def _create() -> dict[str, Any]:
197210
_ctx,
198211
request.session_id,
199212
memory_policy=request.memory_policy,
213+
metadata=request.metadata,
200214
)
201215
return {
202216
"session_id": session.session_id,
203217
"uri": session.uri,
204218
"user": session.user.to_dict(),
219+
"metadata": session.meta.metadata,
205220
}
206221

207-
execution = await run_operation(
208-
operation="session.create",
209-
telemetry=request.telemetry,
210-
fn=_create,
211-
)
222+
try:
223+
execution = await run_operation(
224+
operation="session.create",
225+
telemetry=request.telemetry,
226+
fn=_create,
227+
)
228+
except MetadataValidationError as exc:
229+
return error_response("INVALID_ARGUMENT", str(exc), details={"field": "metadata"})
212230
return Response(status="ok", result=execution.result, telemetry=execution.telemetry)
213231

214232

@@ -357,6 +375,39 @@ async def delete_session(
357375
return Response(status="ok", result={"session_id": session_id})
358376

359377

378+
@router.patch("/{session_id}/metadata")
379+
async def update_session_metadata(
380+
request: UpdateMetadataRequest,
381+
session_id: str = Path(..., description="Session ID"),
382+
replace: bool = Query(
383+
False,
384+
description="If true, replace existing metadata entirely instead of merging.",
385+
),
386+
_ctx: RequestContext = Depends(get_request_context),
387+
):
388+
"""Merge (or replace) session metadata.
389+
390+
By default, keys in the request body are merged into the existing
391+
metadata; pass ``replace=true`` to overwrite the dict entirely.
392+
"""
393+
from openviking.session.session_metadata import MetadataValidationError
394+
from openviking_cli.exceptions import NotFoundError
395+
396+
service = get_service()
397+
try:
398+
metadata = await service.sessions.update_metadata(
399+
session_id,
400+
_ctx,
401+
request.metadata,
402+
replace=replace,
403+
)
404+
except MetadataValidationError as exc:
405+
return error_response("INVALID_ARGUMENT", str(exc), details={"field": "metadata"})
406+
except NotFoundError:
407+
return error_response("NOT_FOUND", f"Session {session_id} not found")
408+
return Response(status="ok", result={"session_id": session_id, "metadata": metadata})
409+
410+
360411
class CommitRequest(BaseModel):
361412
"""Commit request body.
362413

openviking/service/session_service.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
from openviking.session import Session
1616
from openviking.session.memory.memory_type_registry import MemoryTypeRegistry
1717
from openviking.session.memory_policy import MemoryPolicy
18+
from openviking.session.session_metadata import (
19+
MetadataValidationError,
20+
merge_metadata,
21+
validate_metadata,
22+
)
1823
from openviking.storage import VikingDBManager
1924
from openviking.storage.viking_fs import VikingFS
2025
from openviking_cli.exceptions import (
@@ -127,6 +132,7 @@ async def create(
127132
ctx: RequestContext,
128133
session_id: Optional[str] = None,
129134
memory_policy: Optional[Dict[str, Any]] = None,
135+
metadata: Optional[Dict[str, Any]] = None,
130136
) -> Session:
131137
"""Create a session and persist its root path.
132138
@@ -135,6 +141,8 @@ async def create(
135141
session_id: Optional session ID. If provided, creates a session with the given ID.
136142
If None, creates a new session with auto-generated ID.
137143
memory_policy: Optional default extraction policy for future commits.
144+
metadata: Optional free-form per-session metadata dict (project name,
145+
tech-stack preferences, etc.). Validated for size and key count.
138146
139147
Raises:
140148
AlreadyExistsError: If a session with the given ID already exists
@@ -152,13 +160,35 @@ async def create(
152160
set(MemoryTypeRegistry().list_names(include_disabled=False))
153161
)
154162
session.meta.memory_policy = policy.to_dict()
163+
if metadata is not None:
164+
session.meta.metadata = validate_metadata(metadata)
155165
await session.ensure_exists()
156166
self._record_lifecycle_metric("create", "ok")
157167
return session
158168
except Exception:
159169
self._record_lifecycle_metric("create", "error")
160170
raise
161171

172+
async def update_metadata(
173+
self,
174+
session_id: str,
175+
ctx: RequestContext,
176+
metadata: Dict[str, Any],
177+
*,
178+
replace: bool = False,
179+
) -> Optional[Dict[str, Any]]:
180+
"""Merge (or replace) session metadata and persist it.
181+
182+
Returns the resulting metadata dict.
183+
"""
184+
if not isinstance(metadata, dict):
185+
raise MetadataValidationError("metadata must be a JSON object")
186+
session = await self.get(session_id, ctx, auto_create=False)
187+
merged = merge_metadata(session.meta.metadata, metadata, replace=replace)
188+
session.meta.metadata = validate_metadata(merged)
189+
await session._save_meta() # noqa: SLF001 — service intentionally persists meta
190+
return session.meta.metadata
191+
162192
async def get(
163193
self, session_id: str, ctx: RequestContext, *, auto_create: bool = False
164194
) -> Session:
@@ -325,6 +355,7 @@ async def extract(self, session_id: str, ctx: RequestContext) -> List[Any]:
325355
session_id=session_id,
326356
ctx=ctx,
327357
archive_uri=archive_uri,
358+
session_metadata=session.meta.metadata,
328359
)
329360
self._record_lifecycle_metric("extract", "ok")
330361
return memories

openviking/session/compressor_v2.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ async def extract_long_term_memories(
232232
allowed_memory_types: Optional[set[str]] = None,
233233
allow_self_memory: bool = True,
234234
allowed_peer_ids: Optional[set[str]] = None,
235+
session_metadata: Optional[Dict[str, Any]] = None,
235236
) -> List[Context]:
236237
"""Extract long-term memories from messages using v2 templating system.
237238
@@ -308,6 +309,7 @@ async def extract_long_term_memories(
308309
ctx=ctx,
309310
viking_fs=viking_fs,
310311
transaction_handle=transaction_handle,
312+
session_metadata=session_metadata,
311313
)
312314
await context_provider.prepare_extraction_messages()
313315
extract_context = context_provider.get_extract_context()

openviking/session/memory/session_extract_context_provider.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(
6060
ctx: RequestContext = None,
6161
viking_fs: VikingFS = None,
6262
transaction_handle=None,
63+
session_metadata: Optional[Dict[str, Any]] = None,
6364
):
6465
self.messages = list(messages) if isinstance(messages, list) else messages
6566
self.latest_archive_overview = latest_archive_overview
@@ -79,6 +80,7 @@ def __init__(
7980
self._link_enabled = config.memory.link_enabled if config.memory else False
8081
self._vision_messages_prepared = False
8182
self._vision_vlm = None
83+
self._session_metadata = session_metadata
8284

8385
@property
8486
def read_file_contents(self) -> Dict[str, MemoryFile]:
@@ -152,8 +154,12 @@ def get_output_language(self) -> str:
152154
return self._output_language
153155

154156
def instruction(self) -> str:
157+
from openviking.session.session_metadata import render_metadata_prompt_block
158+
155159
output_language = self._output_language
156-
goal = f"""You are a memory extraction agent. Your task is to analyze conversations and update memories.
160+
metadata_block = render_metadata_prompt_block(self._session_metadata)
161+
metadata_section = f"{metadata_block}\n\n" if metadata_block else ""
162+
goal = f"""{metadata_section}You are a memory extraction agent. Your task is to analyze conversations and update memories.
157163
158164
## Workflow
159165
1. Analyze the conversation and pre-fetched context

openviking/session/session.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,11 @@ class SessionMeta:
267267
# process restarts.
268268
keep_recent_count: int = 0
269269
memory_policy: Optional[Dict[str, Any]] = None
270+
# Free-form, project-level personalization (architectural style, tech-stack
271+
# preferences, project name, etc.). Injected into the memory extractor's
272+
# system prompt so a single agent can keep distinct memory layers across
273+
# projects without having to allocate a different agent_id per project.
274+
metadata: Optional[Dict[str, Any]] = None
270275

271276
def to_dict(self) -> Dict[str, Any]:
272277
data = {
@@ -284,6 +289,7 @@ def to_dict(self) -> Dict[str, Any]:
284289
"pending_tokens": self.pending_tokens,
285290
"keep_recent_count": self.keep_recent_count,
286291
"memory_policy": dict(self.memory_policy) if self.memory_policy is not None else None,
292+
"metadata": dict(self.metadata) if self.metadata is not None else None,
287293
}
288294
if self.total_message_count is not None:
289295
data["total_message_count"] = self.total_message_count
@@ -327,6 +333,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionMeta":
327333
pending_tokens=max(0, int(data.get("pending_tokens", 0) or 0)),
328334
keep_recent_count=max(0, int(data.get("keep_recent_count", 0) or 0)),
329335
memory_policy=data.get("memory_policy"),
336+
metadata=data.get("metadata"),
330337
)
331338

332339

@@ -1425,6 +1432,7 @@ async def _run_archive_summary() -> None:
14251432
allowed_memory_types=long_term_memory_types,
14261433
allow_self_memory=self_memory_enabled,
14271434
allowed_peer_ids=allowed_peer_ids,
1435+
session_metadata=self._meta.metadata,
14281436
)
14291437
)
14301438
extraction_labels.append("long_term")

0 commit comments

Comments
 (0)