Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs/engineering/external_conversation_import.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ The capability has five layers:
3. Only ordinary user-visible human and assistant text is projected into the
actor-scoped chat-history store. The projection carries external actor and
event provenance and is marked read-only.
Recorded message dates are preserved. Missing message dates use the source
file's last-modified time, labelled `source_file_modified`; the original
source timestamp remains null and import time is recorded separately.
Re-import repairs older import-time fallbacks only for unchanged events.
Known providers use local Codex, Claude Code, Copilot and Gemini brand assets
in conversation rows and assistant avatars; these do not imply a registered
Von participant identity.
4. Continue creates a new native conversation with a lineage receipt and a
copy of the visible history. The source snapshot remains unchanged.
5. Machine-wide discovery and execution use a persisted batch/item controller.
Expand Down
37 changes: 36 additions & 1 deletion src/backend/services/chat_history_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4853,6 +4853,7 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str:
}

divergence: Dict[str, Any] | None = None
corrected_fallback_timestamp_count = 0
appended_history = prepared_history
action = "new"
if existing is not None:
Expand Down Expand Up @@ -4920,13 +4921,40 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str:
else:
action = "refreshed"

# A missing source timestamp is part of the event fingerprint; its
# display fallback is not. Repair only verified, unchanged events.
if not divergence:
repaired_history = []
for entry in existing_history:
incoming = incoming_by_id.get(entry.get("external_event_id"))
if (
incoming is not None
and "external_source_timestamp_utc" in entry
and entry["external_source_timestamp_utc"] is None
and incoming.get("external_timestamp_source") == "source_file_modified"
and _coerce_datetime(incoming.get("timestamp")) is not None
and (
entry.get("timestamp") != incoming["timestamp"]
or entry.get("external_timestamp_source") != "source_file_modified"
)
):
entry = {
**entry,
"timestamp": incoming["timestamp"],
"external_timestamp_source": "source_file_modified",
}
corrected_fallback_timestamp_count += 1
repaired_history.append(entry)
existing_history = repaired_history

combined_history = [*existing_history, *appended_history]
stored_manifest["synchronisation"] = {
"schema_version": "external_conversation_synchronisation.v1",
"action": action,
"checked_at_utc": now.isoformat(),
"appended_message_count": len(appended_history),
"retained_message_count": len(existing_history),
"corrected_fallback_timestamp_count": corrected_fallback_timestamp_count,
"divergence": divergence,
}
search_segments = [
Expand Down Expand Up @@ -4974,7 +5002,13 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str:
query[f"{EXTERNAL_CONVERSATION_IMPORT_FIELD}.package_sha256"] = (
existing_manifest.get("package_sha256")
)
if appended_history:
if corrected_fallback_timestamp_count:
# Keep the same package compare-and-swap and avoid conflicting
# $push/$set updates to the history array.
set_fields["history"] = combined_history
set_fields["created_at"] = first_message_at
set_on_insert.pop("created_at", None)
elif appended_history:
update["$push"] = {"history": {"$each": appended_history}}
try:
result = chat_history_coll.update_one(
Expand Down Expand Up @@ -5041,6 +5075,7 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str:
"action": action,
"session_id": session_id,
"appended_message_count": len(appended_history),
"corrected_fallback_timestamp_count": corrected_fallback_timestamp_count,
"divergence": divergence,
"canonical_read_back": {
"session_id": session_id,
Expand Down
15 changes: 12 additions & 3 deletions src/backend/services/external_conversation_import_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@


EXTERNAL_CONVERSATION_PACKAGE_SCHEMA_VERSION = "external_conversation_package.v1"
EXTERNAL_CONVERSATION_PARSER_VERSION = "2026-09-16.2"
EXTERNAL_CONVERSATION_PARSER_VERSION = "2026-09-16.3"
EXTERNAL_CONVERSATION_ORIGIN_KIND = "external_conversation_import"
EXTERNAL_CONVERSATION_LINEAGE_SCHEMA_VERSION = "conversation_lineage.v1"
MAX_EXTERNAL_CONVERSATION_SOURCE_BYTES = 32 * 1024 * 1024
Expand Down Expand Up @@ -1460,6 +1460,8 @@ def parse_external_conversation_file(

def _history_projection(
package: ExternalConversationPackage,
*,
source_file_modified_at: datetime,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
history: list[dict[str, Any]] = []
total_chars = 0
Expand Down Expand Up @@ -1489,7 +1491,10 @@ def _history_projection(
{
"role": event.source_role,
"content": projected,
"timestamp": timestamp or datetime.now(timezone.utc),
"timestamp": timestamp or source_file_modified_at,
"external_timestamp_source": (
"source_message" if timestamp else "source_file_modified"
),
"external_event_id": event.event_id,
"external_event_kind": event.event_kind,
"external_source_role": event.source_role,
Expand Down Expand Up @@ -1621,7 +1626,10 @@ def import_external_conversation_file(
source_account=package.source_account,
source_workspace=package.source_workspace,
)
history, projection_report = _history_projection(package)
source_file_modified_at = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
history, projection_report = _history_projection(
package, source_file_modified_at=source_file_modified_at
)
if not history:
raise ExternalConversationImportError(
"The source contains no user-visible human or assistant messages.",
Expand Down Expand Up @@ -1717,6 +1725,7 @@ def import_external_conversation_file(
"parser_version": package.parser_version,
"source_created_at_utc": package.source_created_at_utc,
"source_updated_at_utc": package.source_updated_at_utc,
"source_file_modified_at_utc": source_file_modified_at.isoformat(),
"parent_conversation_id": package.parent_conversation_id,
"raw_document_concept_id": raw_document_concept_id,
"raw_file_copy_concept_id": raw_record_result.get("file_copy_concept_id"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 GitHub Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
10 changes: 10 additions & 0 deletions src/frontend/web/von_interface/static/images/providers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Imported conversation provider logos

Used only to identify the source of imported conversations. Marks belong to their respective owners; they do not indicate endorsement. Assets are served locally.

Retrieved 16 September 2026:

- `codex.png`: unmodified Codex app mark bundled with OpenAI VS Code extension `openai.chatgpt-26.908.40401-darwin-arm64`, asset `codex-app-ga-logo-3e5209898ca3.png`.
- `claude.svg`: https://claude.ai/favicon.svg
- `copilot.svg`: https://raw.githubusercontent.com/primer/octicons/main/icons/copilot-24.svg (GitHub Octicons, MIT; see `OCTICONS-LICENSE`).
- `gemini.svg`: https://www.gstatic.com/lamda/images/gemini_sparkle_aurora_33f86dc0c0257da337c63.svg, linked by https://gemini.google.com/.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 5 additions & 5 deletions src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createChatSteeringControls } from './components/chatSteeringControls.js
import { directConversationRows, activeMessageConversationId, showChatConversation, resetConversationCatalogue, renderMessageConversationRow, mountCatalogueControls, initialiseConversationCatalogue, selectMessageConversation, filterCatalogueRows, catalogueSearchActive, rankCatalogueSearchRows } from './components/conversationCatalogue.js';
import { catalogueHasMore } from './components/conversationCatalogue.js';
import { profileButton, participantAvatar, participantIdentityAvatar } from './components/participantProfile.js';
import { importedConversationAvatar } from './components/importedConversationAvatar.js';
import { setButtonLabel } from './utils/buttonLabel.js';
import { createConversationTray } from './components/conversationTray.js';
import { CONVERSATION_LAYOUT_KEY, CONVERSATION_LAYOUT_KEYS, CONVERSATION_NARROW_QUERY, loadConversationLayoutPreferences, normaliseConversationLayout, saveConversationLayoutPreference } from './utils/conversationLayoutPreferences.js';
Expand Down Expand Up @@ -27535,7 +27536,9 @@ function renderChatSessionTabs(sessions, activeSessionId) {

const header = document.createElement('span');
header.className = 'chat-session-tab-header';
header.append(participantAvatar({ display_name: session.external_conversation ? displayName : 'Von', avatar_url: session.external_conversation ? null : '/static/VonImageBig.png' }));
header.append(session.external_conversation
? importedConversationAvatar(session.external_conversation.provider, displayName, { sidebar: true })
: participantAvatar({ display_name: 'Von', avatar_url: '/static/VonImageBig.png' }));

const pinToggle = document.createElement('button');
pinToggle.type = 'button';
Expand Down Expand Up @@ -38369,10 +38372,7 @@ function appendMessage(sender, message, turnId, hasLlmDebug = false, isHistory =
if (options.contributionTimestamp || timestampStr) messageContainer.dataset.contributionTimestamp = options.contributionTimestamp || timestampStr;
let assistantAvatar;
if (externalActor) {
assistantAvatar = document.createElement('span');
assistantAvatar.className = 'chat-imported-actor-avatar';
assistantAvatar.textContent = String(sender || 'Agent').trim().slice(0, 1).toUpperCase() || 'A';
assistantAvatar.setAttribute('aria-label', `${sender} (imported actor)`);
assistantAvatar = importedConversationAvatar(externalActor.source_actor_id, sender);
} else {
assistantAvatar = document.createElement('img');
assistantAvatar.src = '/static/VonImageBig.png';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const PROVIDERS = {
codex: { label: 'Codex', file: 'codex.png' },
claude_code: { label: 'Claude Code', file: 'claude.svg' },
copilot: { label: 'GitHub Copilot', file: 'copilot.svg' },
gemini: { label: 'Gemini', file: 'gemini.svg' }
};

// Provider branding describes the imported source, not a verified Von identity.
export function importedConversationAvatar(providerOrActor, displayName, { sidebar = false } = {}) {
const provider = String(providerOrActor || '').split(':')[0];
const brand = Object.hasOwn(PROVIDERS, provider) ? PROVIDERS[provider] : null;
const avatar = document.createElement('span');
avatar.className = sidebar ? 'participant-avatar imported-provider-avatar' : 'chat-imported-actor-avatar imported-provider-avatar';
avatar.setAttribute('role', 'img');
avatar.setAttribute('aria-label', `${brand?.label || displayName || 'Agent'} (imported)`);
avatar.title = `${brand?.label || displayName || 'Agent'} · Imported conversation`;
avatar.textContent = String(displayName || brand?.label || 'Agent').trim().slice(0, 1).toUpperCase();
if (brand) {
const image = document.createElement('img');
image.src = `/static/images/providers/${brand.file}`;
image.alt = '';
image.addEventListener('load', () => { avatar.classList.add('has-provider-logo'); });
image.addEventListener('error', () => { image.remove(); avatar.classList.remove('has-provider-logo'); });
avatar.append(image);
}
return avatar;
}
21 changes: 21 additions & 0 deletions src/frontend/web/von_interface/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8415,6 +8415,27 @@ body[data-active-tab="settingsTab"] .tab-content-area {
font-weight: 700;
}

.imported-provider-avatar {
position: relative;
overflow: hidden;
}

.imported-provider-avatar.has-provider-logo {
color: transparent;
background: #fff;
}

.imported-provider-avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
padding: 4px;
box-sizing: border-box;
object-fit: contain;
background: #fff;
}

.external-conversation-bulk-import {
display: flex;
align-items: center;
Expand Down
Loading
Loading