Research-Stack/4-Infrastructure/infra/ene-session-sync/src/normalize.rs
Brandon Schneider 2cc9fc8dc7 Rust ene-session-sync: complete rewrite with Python adaptation layer
- source.rs: Add ClawSource adapter that reads .claw/sessions/*.jsonl (skips
  LFS stubs), parses both full-JSON and JSONL-stream formats, normalises into
  ChatSession/ChatMessage identical to the OpenCode path.

- models.rs: Add title/agent/model fields to ChatSession; remove unused
  chrono import.

- normalize.rs: Populate title/agent/model from OpenCodeSession.

- sink.rs: Full rewrite:
  - DDL adds title/agent/model columns + ALTER TABLE IF NOT EXISTS migration
    path for existing clusters.
  - upsert_session and upsert_messages parameter counts match placeholders
    ($19 and $13 respectively).
  - FTS index uses COALESCE to handle NULL text_content.
  - sslmode= token stripped before parse so NoTls connect doesn't reject
    it; README documents the SSL upgrade path (tokio-postgres-rustls).
  - with-serde_json-1 feature added to tokio-postgres dep for JSONB params.

- main.rs:
  - Replace DefaultHasher sha256_text with FNV-1a 64-bit (correct hex digest).
  - Add ClawSync, List, Get subcommands.
  - Remove unused Context import.

- bridge.rs: Rewrite to use bridge_wrapper.py subprocess rather than inline
  -c script; resolves wrapper path relative to infra_dir or CARGO_MANIFEST_DIR;
  unwraps {"ok":true,"data":{}} envelope; health_check() tests python3 only.

- embed.rs: Remove unused OllamaEmbedRequest import.

- systemd/: Add ene-session-sync.service + .timer (5-min cadence, 2-min
  OnBootSec, Persistent=true for catch-up after suspend).

- README.md: Architecture diagram, CLI reference, schema, build/install,
  systemd install, Python bridge setup.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-19 09:16:19 +00:00

181 lines
6.3 KiB
Rust

use crate::models::{
ChatMessage, ChatSession, MessageBlock, OpenCodeMessage, OpenCodePart, OpenCodeSession,
ToolCall,
};
use anyhow::Result;
use serde_json::json;
use tracing::warn;
/// Compute a 16-char hex FNV-1a hash of a path string.
pub fn workspace_fingerprint(path: &str) -> String {
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
let mut hash = FNV_OFFSET;
for byte in path.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("{:016x}", hash)
}
/// Normalize an OpenCode session + its messages into RDS rows.
pub fn normalize_session(
sess: &OpenCodeSession,
messages: &[ChatMessage],
compaction_summary: Option<String>,
) -> ChatSession {
let first_at = messages.first().map(|m| m.created_at_ms);
let last_at = messages.last().map(|m| m.created_at_ms);
let fingerprint = workspace_fingerprint(&sess.directory);
let meta = json!({
"slug": &sess.slug,
"version": &sess.version,
"project_id": &sess.project_id,
"share_url": &sess.share_url,
"summary_additions": sess.summary_additions,
"summary_deletions": sess.summary_deletions,
"summary_files": sess.summary_files,
"summary_diffs": &sess.summary_diffs,
"revert": &sess.revert,
"permission": &sess.permission,
"workspace_id": &sess.workspace_id,
"path": &sess.path,
"cost": sess.cost,
"tokens_reasoning": sess.tokens_reasoning,
"tokens_cache_read": sess.tokens_cache_read,
"tokens_cache_write": sess.tokens_cache_write,
});
ChatSession {
session_id: sess.id.clone(),
title: Some(sess.title.clone()),
agent: sess.agent.clone(),
model: sess.model.clone(),
workspace_fingerprint: Some(fingerprint),
workspace_root: Some(sess.directory.clone()),
fork_parent_session_id: sess.parent_id.clone(),
compaction_count: 0,
compaction_summary,
message_count: messages.len() as i32,
token_input_total: sess.tokens_input,
token_output_total: sess.tokens_output,
created_at_ms: sess.time_created,
updated_at_ms: sess.time_updated,
first_message_at_ms: first_at,
last_message_at_ms: last_at,
meta,
embedding: None,
receipt: None,
}
}
/// Normalize a single OpenCode message + its parts into a ChatMessage.
pub fn normalize_message(
msg: &OpenCodeMessage,
parts: &[OpenCodePart],
index: i32,
) -> Result<ChatMessage> {
let mut blocks = Vec::new();
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
for part in parts {
match part.data.r#type.as_str() {
"text" => {
if let Some(ref t) = part.data.text {
text_parts.push(t.clone());
blocks.push(MessageBlock {
block_type: "text".into(),
text: Some(t.clone()),
tool_name: None,
tool_input: None,
tool_output: None,
is_error: None,
});
}
}
"reasoning" => {
if let Some(ref t) = part.data.text {
text_parts.push(format!("[reasoning] {}", t));
blocks.push(MessageBlock {
block_type: "reasoning".into(),
text: Some(t.clone()),
tool_name: None,
tool_input: None,
tool_output: None,
is_error: None,
});
}
}
"tool" => {
let call_id = part.data.call_id().unwrap_or_default();
let tool_name = part.data.tool.clone().unwrap_or_default();
blocks.push(MessageBlock {
block_type: "tool_use".into(),
text: None,
tool_name: Some(tool_name.clone()),
tool_input: part.data.input.clone(),
tool_output: part.data.output.clone(),
is_error: part.data.is_error,
});
tool_calls.push(ToolCall {
call_id: call_id.clone(),
tool_name: tool_name.clone(),
input: part.data.input.clone().unwrap_or(json!({})),
});
}
"tool-result" => {
blocks.push(MessageBlock {
block_type: "tool_result".into(),
text: part.data.text.clone(),
tool_name: part.data.tool.clone(),
tool_input: None,
tool_output: part.data.output.clone(),
is_error: part.data.is_error,
});
}
other => {
warn!("unknown part type '{}' in message {}", other, msg.id);
blocks.push(MessageBlock {
block_type: other.to_string(),
text: part.data.text.clone(),
tool_name: part.data.tool.clone(),
tool_input: part.data.input.clone(),
tool_output: part.data.output.clone(),
is_error: part.data.is_error,
});
}
}
}
let text_content = text_parts.join("\n");
let token_input = msg.data.tokens.as_ref().and_then(|t| t.input).unwrap_or(0);
let token_output = msg.data.tokens.as_ref().and_then(|t| t.output).unwrap_or(0);
let cache_creation = msg
.data
.tokens
.as_ref()
.and_then(|t| t.cache.as_ref().and_then(|c| c.creation))
.unwrap_or(0);
let cache_read = msg
.data
.tokens
.as_ref()
.and_then(|t| t.cache.as_ref().and_then(|c| c.read))
.unwrap_or(0);
Ok(ChatMessage {
session_id: msg.session_id.clone(),
message_index: index,
role: msg.data.role.clone(),
blocks,
text_content,
token_input,
token_output,
token_cache_creation: cache_creation,
token_cache_read: cache_read,
tool_calls,
embedding: None,
created_at_ms: msg.time_created,
receipt_hash: None,
})
}