Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions crates/ov_cli/src/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,39 @@ pub async fn commit_session(
Ok(())
}

/// Update (merge) free-form session metadata.
///
/// ``key_values`` is a list of ``key=value`` pairs. Pass ``replace=true`` to
/// overwrite the dict entirely instead of merging.
pub async fn set_session_metadata(
client: &HttpClient,
session_id: &str,
key_values: &[(String, String)],
replace: bool,
output_format: OutputFormat,
compact: bool,
) -> Result<()> {
if key_values.is_empty() {
return Err(Error::Client(
"set-metadata requires at least one --key/--value pair".to_string(),
));
}
let mut metadata = serde_json::Map::new();
for (key, value) in key_values {
metadata.insert(key.clone(), Value::String(value.clone()));
}
let path = format!("/api/v1/sessions/{}/metadata", url_encode(session_id));
let body = json!({"metadata": Value::Object(metadata)});
let params: Vec<(String, String)> = if replace {
vec![("replace".to_string(), "true".to_string())]
} else {
Vec::new()
};
let response: serde_json::Value = client.patch(&path, &body, &params).await?;
output_success(&response, output_format, compact);
Ok(())
}

/// Add memory in one shot: creates a session, adds messages, and commits.
///
/// Input can be:
Expand Down
25 changes: 25 additions & 0 deletions crates/ov_cli/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,31 @@ pub async fn handle_session(cmd: SessionCommands, ctx: CliContext) -> Result<()>
commands::session::commit_session(&client, &session_id, ctx.output_format, ctx.compact)
.await
}
SessionCommands::SetMetadata {
session_id,
keys,
values,
replace,
} => {
if keys.len() != values.len() {
return Err(crate::error::Error::Client(format!(
"set-metadata requires the same number of --key and --value flags (got {} and {})",
keys.len(),
values.len(),
)));
}
let pairs: Vec<(String, String)> =
keys.into_iter().zip(values.into_iter()).collect();
commands::session::set_session_metadata(
&client,
&session_id,
&pairs,
replace,
ctx.output_format,
ctx.compact,
)
.await
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions crates/ov_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,21 @@ enum SessionCommands {
#[arg(value_name = "session-id")]
session_id: String,
},
/// Merge free-form session metadata (or replace it)
SetMetadata {
/// Session ID
#[arg(value_name = "session-id")]
session_id: String,
/// Metadata key (repeatable, paired positionally with --value)
#[arg(long = "key", value_name = "key", num_args = 1..)]
keys: Vec<String>,
/// Metadata value (repeatable, paired positionally with --key)
#[arg(long = "value", value_name = "value", num_args = 1..)]
values: Vec<String>,
/// Replace existing metadata entirely instead of merging
#[arg(long)]
replace: bool,
},
}

#[derive(Subcommand)]
Expand Down
61 changes: 56 additions & 5 deletions openviking/server/routers/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ class CreateSessionRequest(BaseModel):

session_id: Optional[str] = None
memory_policy: Optional[Dict[str, Any]] = None
metadata: Optional[Dict[str, Any]] = None
telemetry: TelemetryRequest = False


class UpdateMetadataRequest(BaseModel):
"""Request model for updating session metadata."""

metadata: Dict[str, Any] = Field(default_factory=dict)
telemetry: TelemetryRequest = False


Expand Down Expand Up @@ -188,7 +196,12 @@ async def create_session(

If session_id is provided, creates a session with the given ID.
If session_id is None, creates a new session with auto-generated ID.
Optional ``metadata`` carries free-form per-session personalization
(project name, tech-stack preferences, etc.) that is later injected into
the memory extractor's prompt.
"""
from openviking.session.session_metadata import MetadataValidationError

service = get_service()

async def _create() -> dict[str, Any]:
Expand All @@ -197,18 +210,23 @@ async def _create() -> dict[str, Any]:
_ctx,
request.session_id,
memory_policy=request.memory_policy,
metadata=request.metadata,
)
return {
"session_id": session.session_id,
"uri": session.uri,
"user": session.user.to_dict(),
"metadata": session.meta.metadata,
}

execution = await run_operation(
operation="session.create",
telemetry=request.telemetry,
fn=_create,
)
try:
execution = await run_operation(
operation="session.create",
telemetry=request.telemetry,
fn=_create,
)
except MetadataValidationError as exc:
return error_response("INVALID_ARGUMENT", str(exc), details={"field": "metadata"})
return Response(status="ok", result=execution.result, telemetry=execution.telemetry)


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


@router.patch("/{session_id}/metadata")
async def update_session_metadata(
request: UpdateMetadataRequest,
session_id: str = Path(..., description="Session ID"),
replace: bool = Query(
False,
description="If true, replace existing metadata entirely instead of merging.",
),
_ctx: RequestContext = Depends(get_request_context),
):
"""Merge (or replace) session metadata.

By default, keys in the request body are merged into the existing
metadata; pass ``replace=true`` to overwrite the dict entirely.
"""
from openviking.session.session_metadata import MetadataValidationError
from openviking_cli.exceptions import NotFoundError

service = get_service()
try:
metadata = await service.sessions.update_metadata(
session_id,
_ctx,
request.metadata,
replace=replace,
)
except MetadataValidationError as exc:
return error_response("INVALID_ARGUMENT", str(exc), details={"field": "metadata"})
except NotFoundError:
return error_response("NOT_FOUND", f"Session {session_id} not found")
return Response(status="ok", result={"session_id": session_id, "metadata": metadata})


class CommitRequest(BaseModel):
"""Commit request body.

Expand Down
31 changes: 31 additions & 0 deletions openviking/service/session_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
from openviking.session import Session
from openviking.session.memory.memory_type_registry import MemoryTypeRegistry
from openviking.session.memory_policy import MemoryPolicy
from openviking.session.session_metadata import (
MetadataValidationError,
merge_metadata,
validate_metadata,
)
from openviking.storage import VikingDBManager
from openviking.storage.viking_fs import VikingFS
from openviking_cli.exceptions import (
Expand Down Expand Up @@ -127,6 +132,7 @@ async def create(
ctx: RequestContext,
session_id: Optional[str] = None,
memory_policy: Optional[Dict[str, Any]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Session:
"""Create a session and persist its root path.

Expand All @@ -135,6 +141,8 @@ async def create(
session_id: Optional session ID. If provided, creates a session with the given ID.
If None, creates a new session with auto-generated ID.
memory_policy: Optional default extraction policy for future commits.
metadata: Optional free-form per-session metadata dict (project name,
tech-stack preferences, etc.). Validated for size and key count.

Raises:
AlreadyExistsError: If a session with the given ID already exists
Expand All @@ -152,13 +160,35 @@ async def create(
set(MemoryTypeRegistry().list_names(include_disabled=False))
)
session.meta.memory_policy = policy.to_dict()
if metadata is not None:
session.meta.metadata = validate_metadata(metadata)
await session.ensure_exists()
self._record_lifecycle_metric("create", "ok")
return session
except Exception:
self._record_lifecycle_metric("create", "error")
raise

async def update_metadata(
self,
session_id: str,
ctx: RequestContext,
metadata: Dict[str, Any],
*,
replace: bool = False,
) -> Optional[Dict[str, Any]]:
"""Merge (or replace) session metadata and persist it.

Returns the resulting metadata dict.
"""
if not isinstance(metadata, dict):
raise MetadataValidationError("metadata must be a JSON object")
session = await self.get(session_id, ctx, auto_create=False)
merged = merge_metadata(session.meta.metadata, metadata, replace=replace)
session.meta.metadata = validate_metadata(merged)
await session._save_meta() # noqa: SLF001 — service intentionally persists meta
return session.meta.metadata

async def get(
self, session_id: str, ctx: RequestContext, *, auto_create: bool = False
) -> Session:
Expand Down Expand Up @@ -325,6 +355,7 @@ async def extract(self, session_id: str, ctx: RequestContext) -> List[Any]:
session_id=session_id,
ctx=ctx,
archive_uri=archive_uri,
session_metadata=session.meta.metadata,
)
self._record_lifecycle_metric("extract", "ok")
return memories
2 changes: 2 additions & 0 deletions openviking/session/compressor_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ async def extract_long_term_memories(
allowed_memory_types: Optional[set[str]] = None,
allow_self_memory: bool = True,
allowed_peer_ids: Optional[set[str]] = None,
session_metadata: Optional[Dict[str, Any]] = None,
) -> List[Context]:
"""Extract long-term memories from messages using v2 templating system.

Expand Down Expand Up @@ -308,6 +309,7 @@ async def extract_long_term_memories(
ctx=ctx,
viking_fs=viking_fs,
transaction_handle=transaction_handle,
session_metadata=session_metadata,
)
await context_provider.prepare_extraction_messages()
extract_context = context_provider.get_extract_context()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ def __init__(
ctx: RequestContext = None,
viking_fs: VikingFS = None,
transaction_handle=None,
session_metadata: Optional[Dict[str, Any]] = None,
):
self.messages = list(messages) if isinstance(messages, list) else messages
self.latest_archive_overview = latest_archive_overview
Expand All @@ -84,6 +85,7 @@ def __init__(
self._link_enabled = config.memory.link_enabled if config.memory else False
self._vision_messages_prepared = False
self._vision_vlm = None
self._session_metadata = session_metadata

@property
def read_file_contents(self) -> Dict[str, MemoryFile]:
Expand Down Expand Up @@ -185,6 +187,8 @@ def _conversation_contains_resource_uri(self) -> bool:
return False

def instruction(self) -> str:
from openviking.session.session_metadata import render_metadata_prompt_block

output_language = self._output_language
resource_uri_handling = (
"""
Expand All @@ -200,7 +204,9 @@ def instruction(self) -> str:
if self._conversation_contains_resource_uri()
else ""
)
goal = f"""You are a memory extraction agent. Your task is to analyze conversations and update memories.
metadata_block = render_metadata_prompt_block(self._session_metadata)
metadata_section = f"{metadata_block}\n\n" if metadata_block else ""
goal = f"""{metadata_section}You are a memory extraction agent. Your task is to analyze conversations and update memories.

## Workflow
1. Analyze the conversation and pre-fetched context
Expand Down
8 changes: 8 additions & 0 deletions openviking/session/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ class SessionMeta:
# process restarts.
keep_recent_count: int = 0
memory_policy: Optional[Dict[str, Any]] = None
# Free-form, project-level personalization (architectural style, tech-stack
# preferences, project name, etc.). Injected into the memory extractor's
# system prompt so a single agent can keep distinct memory layers across
# projects without having to allocate a different agent_id per project.
metadata: Optional[Dict[str, Any]] = None

def to_dict(self) -> Dict[str, Any]:
data = {
Expand All @@ -284,6 +289,7 @@ def to_dict(self) -> Dict[str, Any]:
"pending_tokens": self.pending_tokens,
"keep_recent_count": self.keep_recent_count,
"memory_policy": dict(self.memory_policy) if self.memory_policy is not None else None,
"metadata": dict(self.metadata) if self.metadata is not None else None,
}
if self.total_message_count is not None:
data["total_message_count"] = self.total_message_count
Expand Down Expand Up @@ -327,6 +333,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionMeta":
pending_tokens=max(0, int(data.get("pending_tokens", 0) or 0)),
keep_recent_count=max(0, int(data.get("keep_recent_count", 0) or 0)),
memory_policy=data.get("memory_policy"),
metadata=data.get("metadata"),
)


Expand Down Expand Up @@ -1425,6 +1432,7 @@ async def _run_archive_summary() -> None:
allowed_memory_types=long_term_memory_types,
allow_self_memory=self_memory_enabled,
allowed_peer_ids=allowed_peer_ids,
session_metadata=self._meta.metadata,
)
)
extraction_labels.append("long_term")
Expand Down
Loading
Loading