code-context-control 2.28.3__py3-none-any.whl → 2.29.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.
cli/c3.py CHANGED
@@ -827,6 +827,11 @@ def cmd_init(args):
827
827
  if not c3_dir.exists() or not (c3_dir / "config.json").exists():
828
828
  print_header(f"Initializing C3 for: {project_path}")
829
829
  _do_init(project_path, ide_name=requested_ide)
830
+ try:
831
+ from services.project_manager import ProjectManager
832
+ ProjectManager().add_project(project_path)
833
+ except Exception as _e:
834
+ print(f" [warn] Could not register project with hub: {_e}")
830
835
  if getattr(args, "force", False):
831
836
  if git_requested:
832
837
  _init_local_git_repo(project_path)
cli/hub.html CHANGED
@@ -995,6 +995,49 @@
995
995
  </div>
996
996
  </div>
997
997
 
998
+ <!-- Merge Modal -->
999
+ <div class="modal-backdrop hidden" id="merge-modal">
1000
+ <div class="modal">
1001
+ <div class="modal-header">
1002
+ <h3>Merge Projects</h3>
1003
+ <button class="modal-close" onclick="closeModal('merge-modal')">×</button>
1004
+ </div>
1005
+ <div class="modal-body">
1006
+ <div class="form-group">
1007
+ <span class="form-label">Merging from</span>
1008
+ <div class="form-path" id="merge-modal-source-path"></div>
1009
+ </div>
1010
+ <div class="form-group">
1011
+ <label class="form-label" for="merge-target-select">Merge into</label>
1012
+ <select class="form-input" id="merge-target-select"></select>
1013
+ </div>
1014
+ <div class="form-group">
1015
+ <span class="form-label">After merge</span>
1016
+ <div style="display:flex;flex-direction:column;gap:.4rem;margin-top:.3rem;">
1017
+ <label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;">
1018
+ <input type="radio" name="merge-cleanup" value="keep" checked onchange="updateMergeWarning()">
1019
+ <span>Keep source project intact</span>
1020
+ </label>
1021
+ <label style="display:flex;align-items:center;gap:.5rem;font-weight:normal;cursor:pointer;">
1022
+ <input type="radio" name="merge-cleanup" value="clear" onchange="updateMergeWarning()">
1023
+ <span>Clear source (wipe .c3/, MCP configs &amp; instruction docs)</span>
1024
+ </label>
1025
+ </div>
1026
+ </div>
1027
+ <div id="merge-warning" style="display:none;border:1px solid #b13c3c;background:rgba(177,60,60,.1);padding:.6rem .75rem;border-radius:4px;margin-top:.5rem;color:var(--text);font-size:.85rem;">
1028
+ ⚠ The source project's <code>.c3/</code> directory, MCP configs (<code>.mcp.json</code>, <code>.claude/settings.local.json</code>, <code>.codex/</code>) and instruction docs (CLAUDE.md, GEMINI.md, AGENTS.md) will be deleted. The source directory itself stays in place.
1029
+ </div>
1030
+ <p style="font-size:.8rem;color:var(--text-muted);margin-top:.5rem;">
1031
+ Combines memory facts, conversation sessions, and edit-ledger entries into the target. File-memory and indices are not merged.
1032
+ </p>
1033
+ </div>
1034
+ <div class="modal-footer">
1035
+ <button class="btn btn-ghost" onclick="closeModal('merge-modal')">Cancel</button>
1036
+ <button class="btn btn-primary" id="merge-save-btn" onclick="saveMerge()">Merge</button>
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+
998
1041
  <!-- Settings Modal -->
999
1042
  <div class="modal-backdrop hidden" id="settings-modal">
1000
1043
  <div class="modal">
@@ -1186,6 +1229,8 @@ let sessionPollTimer = null;
1186
1229
  let modalPath = null;
1187
1230
  let editPath = null;
1188
1231
  let transferPath = null;
1232
+ let mergeSourcePath = null;
1233
+ let mergeSourceName = '';
1189
1234
  let idePath = null;
1190
1235
  let ideSelected = null;
1191
1236
  let mcpModalPath = null;
@@ -1261,7 +1306,7 @@ document.addEventListener('DOMContentLoaded', async () => {
1261
1306
  );
1262
1307
  document.addEventListener('keydown', e => {
1263
1308
  if (e.key === 'Escape') {
1264
- ['mcp-modal','edit-modal','transfer-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
1309
+ ['mcp-modal','edit-modal','transfer-modal','merge-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
1265
1310
  if (!document.getElementById(id).classList.contains('hidden')) closeModal(id);
1266
1311
  });
1267
1312
  }
@@ -1712,6 +1757,7 @@ function projectCard(p) {
1712
1757
  const editBtn = `<button class="btn btn-xs btn-ghost" onclick="openEditModal('${jsq(p.path)}','${jsq(p.name)}','${jsq((p.tags||[]).join(','))}')">✏ Edit</button>`;
1713
1758
  const ideBtn = `<button class="btn btn-xs btn-primary" onclick="openIdeModal('${jsq(p.path)}','${jsq(p.ide)}')">&#9654; IDE</button>`;
1714
1759
  const transferBtn = !active ? `<button class="btn btn-xs btn-ghost" onclick="openTransferModal('${jsq(p.path)}','${jsq(p.name)}')">Transfer</button>` : '';
1760
+ const mergeBtn = !active ? `<button class="btn btn-xs btn-ghost" onclick="openMergeModal('${jsq(p.path)}','${jsq(p.name)}')" title="Merge memory, sessions and edit ledger into another project">⇄ Merge</button>` : '';
1715
1761
  const removeBtn = !active ? `<button class="btn btn-xs btn-danger" onclick="removeProject('${jsq(p.path)}','${jsq(p.name)}')">✕ Remove</button>` : '';
1716
1762
 
1717
1763
  const isLaunching = !active && launchingPaths.has(p.path);
@@ -1741,7 +1787,7 @@ function projectCard(p) {
1741
1787
  ${startBtn}${startUiBtn}${openBtn}${restartBtn}${stopBtn}${autostartBtn}${folderBtn}${logBtn}${ledgerBtn}${setupBtn}${ideBtn}
1742
1788
  </div>
1743
1789
  <div class="card-actions-secondary">
1744
- ${clearNotifBtn}${editBtn}${transferBtn}${removeBtn}
1790
+ ${clearNotifBtn}${editBtn}${transferBtn}${mergeBtn}${removeBtn}
1745
1791
  </div>
1746
1792
  </div>
1747
1793
  </div>`;
@@ -2977,6 +3023,84 @@ async function saveTransfer() {
2977
3023
  }
2978
3024
  }
2979
3025
 
3026
+ // ── Merge Modal ───────────────────────────────────────────────────────────
3027
+
3028
+ function openMergeModal(path, name) {
3029
+ mergeSourcePath = path;
3030
+ mergeSourceName = name;
3031
+ document.getElementById('merge-modal-source-path').textContent = `${name} (${path})`;
3032
+ // Populate target dropdown with idle projects (exclude source and active sessions).
3033
+ const sel = document.getElementById('merge-target-select');
3034
+ sel.innerHTML = '';
3035
+ const candidates = (allProjects || []).filter(p => p.path !== path && !p.session_active && p.port == null);
3036
+ if (!candidates.length) {
3037
+ const opt = document.createElement('option');
3038
+ opt.value = '';
3039
+ opt.textContent = '— No eligible target projects —';
3040
+ sel.appendChild(opt);
3041
+ document.getElementById('merge-save-btn').disabled = true;
3042
+ } else {
3043
+ candidates.forEach(p => {
3044
+ const opt = document.createElement('option');
3045
+ opt.value = p.path;
3046
+ opt.textContent = `${p.name} (${p.path})`;
3047
+ sel.appendChild(opt);
3048
+ });
3049
+ document.getElementById('merge-save-btn').disabled = false;
3050
+ }
3051
+ // Reset cleanup radio + warning
3052
+ document.querySelectorAll('input[name="merge-cleanup"]').forEach(r => { r.checked = (r.value === 'keep'); });
3053
+ updateMergeWarning();
3054
+ openModal('merge-modal');
3055
+ }
3056
+
3057
+ function updateMergeWarning() {
3058
+ const cleanup = (document.querySelector('input[name="merge-cleanup"]:checked') || {}).value || 'keep';
3059
+ document.getElementById('merge-warning').style.display = cleanup === 'clear' ? 'block' : 'none';
3060
+ }
3061
+
3062
+ async function saveMerge() {
3063
+ if (!mergeSourcePath) return;
3064
+ const target = document.getElementById('merge-target-select').value;
3065
+ if (!target) { toast('Pick a target project', 'err'); return; }
3066
+ const cleanup = (document.querySelector('input[name="merge-cleanup"]:checked') || {}).value || 'keep';
3067
+ if (cleanup === 'clear') {
3068
+ const ok = await confirmDialog({
3069
+ title: 'Clear source after merge?',
3070
+ message: `This will permanently delete .c3/, MCP configs and instruction docs from "${mergeSourceName}". The merged data will live on inside the target. Continue?`,
3071
+ confirmText: 'Merge & clear',
3072
+ danger: true,
3073
+ });
3074
+ if (!ok) return;
3075
+ }
3076
+ const btn = document.getElementById('merge-save-btn');
3077
+ btn.disabled = true;
3078
+ try {
3079
+ const r = await fetch('/api/projects/merge', {
3080
+ method: 'POST',
3081
+ headers: {'Content-Type':'application/json'},
3082
+ body: JSON.stringify({source_path: mergeSourcePath, target_path: target, cleanup}),
3083
+ });
3084
+ const d = await r.json();
3085
+ if (d.error) throw new Error(d.error);
3086
+ if (!d.merged) throw new Error('Merge did not complete');
3087
+ const s = d.stats || {};
3088
+ const summary = `Merged ${s.facts||0} facts, ${s.sessions||0} sessions, ${s.ledger_entries||0} ledger entries`;
3089
+ toast(summary + (cleanup === 'clear' ? ' — source cleared' : ''), 'ok');
3090
+ if (d.warnings && d.warnings.length) {
3091
+ console.warn('merge warnings:', d.warnings);
3092
+ }
3093
+ closeModal('merge-modal');
3094
+ delete detailsCache[mergeSourcePath];
3095
+ delete detailsCache[target];
3096
+ loadProjects();
3097
+ } catch(e) {
3098
+ toast('Merge error: ' + e.message, 'err');
3099
+ } finally {
3100
+ btn.disabled = false;
3101
+ }
3102
+ }
3103
+
2980
3104
  // ── Settings Modal ─────────────────────────────────────────────────────────
2981
3105
  async function openSettings() {
2982
3106
  try {
@@ -3166,11 +3290,12 @@ function closeModal(id) {
3166
3290
  if (id === 'update-modal') { modalPath = null; }
3167
3291
  if (id === 'edit-modal') { editPath = null; }
3168
3292
  if (id === 'transfer-modal') { transferPath = null; }
3293
+ if (id === 'merge-modal') { mergeSourcePath = null; mergeSourceName = ''; }
3169
3294
  if (id === 'c3-setup-modal') { c3SetupPath = null; }
3170
3295
  }
3171
3296
 
3172
3297
  // Close modals on backdrop click
3173
- ['mcp-modal','edit-modal','transfer-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
3298
+ ['mcp-modal','edit-modal','transfer-modal','merge-modal','settings-modal','update-modal','batch-update-modal','ide-modal','c3-setup-modal'].forEach(id => {
3174
3299
  document.getElementById(id).addEventListener('click', e => {
3175
3300
  if (e.target === e.currentTarget) closeModal(id);
3176
3301
  });
cli/hub_server.py CHANGED
@@ -699,6 +699,29 @@ def api_projects_transfer():
699
699
  return jsonify({"error": str(e)}), 500
700
700
 
701
701
 
702
+ @app.route("/api/projects/merge", methods=["POST"])
703
+ def api_projects_merge():
704
+ """Merge source project's memory/sessions/ledger into target.
705
+
706
+ Body: {source_path, target_path, cleanup: 'keep'|'clear'}
707
+ """
708
+ data = request.get_json(force=True) or {}
709
+ src = (data.get("source_path") or "").strip()
710
+ tgt = (data.get("target_path") or "").strip()
711
+ cleanup = (data.get("cleanup") or "keep").strip().lower()
712
+ if not src or not tgt:
713
+ return jsonify({"error": "source_path and target_path are required"}), 400
714
+ if cleanup not in ("keep", "clear"):
715
+ return jsonify({"error": "cleanup must be 'keep' or 'clear'"}), 400
716
+ try:
717
+ result = _pm().merge_projects(src, tgt, cleanup=cleanup)
718
+ if result.get("error"):
719
+ return jsonify(result), 400
720
+ return jsonify(result)
721
+ except Exception as e:
722
+ return jsonify({"error": str(e)}), 500
723
+
724
+
702
725
  @app.route("/api/projects/details", methods=["POST"])
703
726
  def api_projects_details():
704
727
  data = request.get_json(force=True) or {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-context-control
3
- Version: 2.28.3
3
+ Version: 2.29.0
4
4
  Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer.
5
5
  Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
2
2
  cli/_hook_utils.py,sha256=1_hTA-Wz62xB8jnSAH4C5TfCkrwEP0g2kq_-oRfQLm4,3724
3
- cli/c3.py,sha256=9iZCyg3HNadWFSoHjm9oTWIfIKAlSkVJH0FnzIR_xs0,278815
3
+ cli/c3.py,sha256=2tgG0qDN6DF0xPt298c3y_DiHV4jvg93rAkdI2QjHCQ,279052
4
4
  cli/docs.html,sha256=qlkXoSfnh8yz78LpkrhzsjTieZJuStlzdo9bktkyQSg,140621
5
5
  cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
6
6
  cli/hook_auto_snapshot.py,sha256=amtliVDzKUQr6KBR0pdBA8vXghAV-gKr19jBaJVnP_w,5006
@@ -14,8 +14,8 @@ cli/hook_pretool_enforce.py,sha256=Mo9b6SyjlCuwPnkbNSX0RDfNkmt5u_YeEfdY9Q79RKc,1
14
14
  cli/hook_read.py,sha256=M5l_SU899O72tZe3j4YQJJKNb1-xulvKOj8XZjJzwYU,8021
15
15
  cli/hook_session_stats.py,sha256=a1OKi9kmiXRI2qieY_Uq14xRxdXQTQu9WVzDTUlI0GQ,1897
16
16
  cli/hook_terse_advisor.py,sha256=pD7Bap7OYOKqtYz7cX8nWSRLH7ook-tSD2Ov2MNp_sA,5907
17
- cli/hub.html,sha256=RkTS91KrlY7XsDpdp-CmVQe6JbeRrCv4FXqWrLvqKas,177884
18
- cli/hub_server.py,sha256=gPyYH7WBt_oVLCqFhNf_GBpzBmR1IohLvf82w_ze8bY,61076
17
+ cli/hub.html,sha256=Hl-XPZGT1mMiKrbX9c5OsEw6mXEumwIB3vp1WlWaplM,183966
18
+ cli/hub_server.py,sha256=gnUJdCgX5ZKZHwLKLYW5ki-P_9HH9Zyi_Hb_yoSKwSI,61979
19
19
  cli/mcp_proxy.py,sha256=92htuT-p0j-cDTbyqlIJpGoQ85_Aw7UuB8L_Toi_u20,17511
20
20
  cli/mcp_server.py,sha256=DsAPun5WIAmMkn9LISU9HMTCbJJGRaDXCCyRLbI0NP4,28286
21
21
  cli/server.py,sha256=Or1emfUbBslscikFqfZ1-w8CRYfTdCSKJJshhBHfD3w,109665
@@ -54,7 +54,7 @@ cli/ui/components/memory.js,sha256=v5IsHTxLHpXX4xCsUaZ_UPprZEabdgP4jiWc298iV2U,2
54
54
  cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE,30837
55
55
  cli/ui/components/settings.js,sha256=8LVTV2TQl9tcRXhXbtBEJOCBdiyk-x2QASoVYZUAuEA,71442
56
56
  cli/ui/components/sidebar.js,sha256=cAY_jwYB-o1X_wWn__VXlG4IegVObuE3NmVsuFWqxtg,7417
57
- code_context_control-2.28.3.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
57
+ code_context_control-2.29.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
58
58
  core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
59
59
  core/config.py,sha256=4zrJtooWn78JQMsuMWabfh7mCithCCQzVfRsHI7TMsQ,11768
60
60
  core/ide.py,sha256=9LzsDVK2LL8RVpL40l6oNGiasZ3D8OCU_9i9A0gJKBo,6876
@@ -105,7 +105,7 @@ services/notifications.py,sha256=pyESc6vgV_bBQPppcR4TcjmoHsqIaFTU_J1bvjJbx5Q,865
105
105
  services/ollama_client.py,sha256=UiC5Abca622-ac-4goCkAjwo7iUye9g9JGFMN6Ea08M,7983
106
106
  services/output_filter.py,sha256=097HPxy0BM8fXePktb7sOf-Yfkx4qqHy0HurNRjhNDY,18635
107
107
  services/parser.py,sha256=vv3B4Di1q4Sr2QFAxvsk5tsYNVcAEw8oAQMhXjLURDE,54011
108
- services/project_manager.py,sha256=-qJGY1NwXsLYC6C9jQ471BLZq9HDMPX2gXddeKC15bU,23573
108
+ services/project_manager.py,sha256=zHT4Ll9fSD09M1v-PpeoVO0G0DueCm2lbtVc3GlxbjA,35304
109
109
  services/protocol.py,sha256=gfvk4y-bEwGlXlOwBoyqKuADXe42NAKfdWl3BAzyVJc,11962
110
110
  services/proxy_state.py,sha256=u5rd0k6CrOsywZA8FpRu_hMLwhR0TAJhZjy5MdWbCGc,6107
111
111
  services/retrieval_broker.py,sha256=9X67VZ_6AkbAzopHuuMFKmP4CGZLnW576kjSKMenBnw,5261
@@ -143,8 +143,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
143
143
  tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
144
144
  tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
145
145
  tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
146
- code_context_control-2.28.3.dist-info/METADATA,sha256=99UO9rQeMyMix69JHMbMSWCA8babojNUze1jiblmGUw,16096
147
- code_context_control-2.28.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
148
- code_context_control-2.28.3.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
149
- code_context_control-2.28.3.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
150
- code_context_control-2.28.3.dist-info/RECORD,,
146
+ code_context_control-2.29.0.dist-info/METADATA,sha256=aosiSsHji1aTcG5hPfeRJv0_S6DfyUwFhO-kNIeo53Y,16096
147
+ code_context_control-2.29.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
148
+ code_context_control-2.29.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
149
+ code_context_control-2.29.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
150
+ code_context_control-2.29.0.dist-info/RECORD,,
@@ -577,3 +577,247 @@ class ProjectManager:
577
577
 
578
578
  self._write_projects(projects)
579
579
  return {"transferred": True, "old_path": old_path, "new_path": new_path}
580
+
581
+ def merge_projects(self, source_path: str, target_path: str, cleanup: str = "keep") -> dict:
582
+ """Merge source project's memory/sessions/ledger into target.
583
+
584
+ Combines facts (.c3/facts/facts.json), edit-ledger entries
585
+ (.c3/edit_ledger.jsonl), conversation sessions (.c3/conversations/),
586
+ and unions registry tags + appends notes. Skips file_memory and
587
+ indices because their paths reference source files that don't
588
+ exist in the target.
589
+
590
+ Args:
591
+ source_path: project being merged FROM.
592
+ target_path: project being merged INTO.
593
+ cleanup: "keep" leaves source untouched after the merge;
594
+ "clear" wipes source's .c3/, MCP configs and
595
+ instruction docs (equivalent to ``c3 init --clear``)
596
+ and removes its registry entry.
597
+
598
+ Returns:
599
+ ``{"merged": True, "source", "target", "cleanup", "stats": {...}}``
600
+ on success or ``{"merged": False, "error": "..."}`` on validation
601
+ failure.
602
+ """
603
+ import shutil
604
+ import uuid
605
+
606
+ if cleanup not in ("keep", "clear"):
607
+ return {"merged": False, "error": "cleanup must be 'keep' or 'clear'"}
608
+
609
+ src = Path(source_path).resolve()
610
+ tgt = Path(target_path).resolve()
611
+
612
+ if str(src) == str(tgt):
613
+ return {"merged": False, "error": "Paths are identical"}
614
+ if not src.is_dir():
615
+ return {"merged": False, "error": "Source path does not exist"}
616
+ if not tgt.is_dir():
617
+ return {"merged": False, "error": "Target path does not exist"}
618
+ if not (src / ".c3").is_dir():
619
+ return {"merged": False, "error": "Source has no .c3 directory"}
620
+ if not (tgt / ".c3").is_dir():
621
+ return {"merged": False, "error": "Target has no .c3 directory"}
622
+
623
+ projects = self._read_projects()
624
+ src_entry = next((p for p in projects if p.get("path") == str(src)), None)
625
+ tgt_entry = next((p for p in projects if p.get("path") == str(tgt)), None)
626
+ if src_entry is None:
627
+ return {"merged": False, "error": "Source project not registered"}
628
+ if tgt_entry is None:
629
+ return {"merged": False, "error": "Target project not registered"}
630
+
631
+ src_name = src_entry.get("name") or src.name
632
+ slug = "".join(c if c.isalnum() else "_" for c in src_name.lower())[:32] or "merged"
633
+ merge_tag = f"merged:{slug}"
634
+ merged_at = datetime.utcnow().isoformat() + "Z"
635
+
636
+ stats = {"facts": 0, "ledger_entries": 0, "sessions": 0}
637
+ warnings: list[str] = []
638
+
639
+ # ── Facts ────────────────────────────────────────────────────
640
+ src_facts_file = src / ".c3" / "facts" / "facts.json"
641
+ tgt_facts_dir = tgt / ".c3" / "facts"
642
+ tgt_facts_file = tgt_facts_dir / "facts.json"
643
+
644
+ if src_facts_file.exists():
645
+ try:
646
+ with open(src_facts_file, encoding="utf-8") as f:
647
+ src_facts = json.load(f) or []
648
+ except Exception as e:
649
+ src_facts = []
650
+ warnings.append(f"facts read failed: {e}")
651
+
652
+ if src_facts:
653
+ tgt_facts: list = []
654
+ if tgt_facts_file.exists():
655
+ try:
656
+ with open(tgt_facts_file, encoding="utf-8") as f:
657
+ tgt_facts = json.load(f) or []
658
+ except Exception as e:
659
+ warnings.append(f"target facts read failed: {e}")
660
+ tgt_facts = []
661
+ for fact in src_facts:
662
+ if not isinstance(fact, dict):
663
+ continue
664
+ new_fact = dict(fact)
665
+ new_id = uuid.uuid4().hex[:12]
666
+ new_fact["id"] = new_id
667
+ new_fact["vector_id"] = new_id
668
+ new_fact["merged_from"] = src_name
669
+ new_fact["merged_at"] = merged_at
670
+ tgt_facts.append(new_fact)
671
+ stats["facts"] += 1
672
+ tgt_facts_dir.mkdir(parents=True, exist_ok=True)
673
+ with open(tgt_facts_file, "w", encoding="utf-8") as f:
674
+ json.dump(tgt_facts, f, indent=2)
675
+
676
+ # ── Edit ledger ──────────────────────────────────────────────
677
+ src_ledger = src / ".c3" / "edit_ledger.jsonl"
678
+ tgt_ledger = tgt / ".c3" / "edit_ledger.jsonl"
679
+ if src_ledger.exists():
680
+ tgt_ledger.parent.mkdir(parents=True, exist_ok=True)
681
+ try:
682
+ with open(tgt_ledger, "a", encoding="utf-8") as out:
683
+ for line in src_ledger.read_text(encoding="utf-8").splitlines():
684
+ if not line.strip():
685
+ continue
686
+ try:
687
+ entry = json.loads(line)
688
+ except Exception:
689
+ continue
690
+ summary = entry.get("summary") or ""
691
+ entry["summary"] = f"[merged from {src_name}] {summary}".rstrip()
692
+ tags = list(entry.get("tags") or [])
693
+ if merge_tag not in tags:
694
+ tags.append(merge_tag)
695
+ entry["tags"] = tags
696
+ entry["merged_from"] = src_name
697
+ out.write(json.dumps(entry) + "\n")
698
+ stats["ledger_entries"] += 1
699
+ except Exception as e:
700
+ warnings.append(f"ledger merge failed: {e}")
701
+
702
+ # ── Conversations ────────────────────────────────────────────
703
+ src_conv = src / ".c3" / "conversations"
704
+ tgt_conv = tgt / ".c3" / "conversations"
705
+ if src_conv.is_dir():
706
+ tgt_conv.mkdir(parents=True, exist_ok=True)
707
+ renamed: dict[str, str] = {} # old_id -> new_id
708
+
709
+ # Load target sessions index up-front so we know what IDs collide.
710
+ tgt_sessions: list = []
711
+ tgt_sessions_file = tgt_conv / "sessions.json"
712
+ if tgt_sessions_file.exists():
713
+ try:
714
+ with open(tgt_sessions_file, encoding="utf-8") as f:
715
+ tgt_sessions = json.load(f) or []
716
+ except Exception as e:
717
+ warnings.append(f"target sessions read failed: {e}")
718
+ tgt_sessions = []
719
+ tgt_ids = {s.get("session_id") for s in tgt_sessions if s.get("session_id")}
720
+
721
+ # Copy turn files (rename on collision).
722
+ for entry_path in src_conv.iterdir():
723
+ if not entry_path.is_file():
724
+ continue
725
+ if entry_path.name == "sessions.json":
726
+ continue
727
+ if entry_path.name.endswith(".jsonl.gz"):
728
+ base = entry_path.name[:-len(".jsonl.gz")]
729
+ ext = ".jsonl.gz"
730
+ elif entry_path.suffix == ".jsonl":
731
+ base = entry_path.stem
732
+ ext = ".jsonl"
733
+ else:
734
+ continue
735
+ new_base = base
736
+ if base in tgt_ids or (tgt_conv / (base + ext)).exists():
737
+ new_base = f"{base}_merged_{uuid.uuid4().hex[:6]}"
738
+ renamed[base] = new_base
739
+ try:
740
+ shutil.copy2(entry_path, tgt_conv / (new_base + ext))
741
+ except Exception as e:
742
+ warnings.append(f"session copy {entry_path.name} failed: {e}")
743
+
744
+ # Merge sessions index.
745
+ src_sessions_file = src_conv / "sessions.json"
746
+ if src_sessions_file.exists():
747
+ try:
748
+ with open(src_sessions_file, encoding="utf-8") as f:
749
+ src_sessions = json.load(f) or []
750
+ except Exception as e:
751
+ src_sessions = []
752
+ warnings.append(f"source sessions read failed: {e}")
753
+ for s in src_sessions:
754
+ if not isinstance(s, dict):
755
+ continue
756
+ new_s = dict(s)
757
+ sid = new_s.get("session_id", "")
758
+ if sid in renamed:
759
+ new_s["session_id"] = renamed[sid]
760
+ elif sid and sid in tgt_ids:
761
+ new_s["session_id"] = f"{sid}_merged_{uuid.uuid4().hex[:6]}"
762
+ new_s["merged_from"] = src_name
763
+ new_s["merged_at"] = merged_at
764
+ tgt_sessions.append(new_s)
765
+ stats["sessions"] += 1
766
+ with open(tgt_sessions_file, "w", encoding="utf-8") as f:
767
+ json.dump(tgt_sessions, f, ensure_ascii=False, indent=2)
768
+ else:
769
+ # No index in source — count copied turn files as sessions.
770
+ stats["sessions"] = len(list(tgt_conv.iterdir())) - (1 if tgt_sessions_file.exists() else 0)
771
+
772
+ # ── Registry tags + notes ────────────────────────────────────
773
+ for p in projects:
774
+ if p.get("path") == str(tgt):
775
+ tags = list(p.get("tags") or [])
776
+ for t in (src_entry.get("tags") or []):
777
+ if t and t not in tags:
778
+ tags.append(t)
779
+ p["tags"] = tags
780
+ src_notes = (src_entry.get("notes") or "").strip()
781
+ if src_notes:
782
+ tgt_notes = (p.get("notes") or "").strip()
783
+ sep = f"--- merged from {src_name} ---"
784
+ p["notes"] = f"{tgt_notes}\n\n{sep}\n{src_notes}".strip() if tgt_notes else f"{sep}\n{src_notes}"
785
+ break
786
+ self._write_projects(projects)
787
+
788
+ # ── Cleanup ──────────────────────────────────────────────────
789
+ if cleanup == "clear":
790
+ try:
791
+ # Lazy import to avoid services -> cli circular import at module load.
792
+ from cli.c3 import _instruction_documents_for_project, _uninstall_mcp_all
793
+ try:
794
+ _uninstall_mcp_all(str(src))
795
+ except Exception as e:
796
+ warnings.append(f"uninstall_mcp failed: {e}")
797
+ c3_dir = src / ".c3"
798
+ if c3_dir.exists():
799
+ try:
800
+ shutil.rmtree(c3_dir)
801
+ except Exception as e:
802
+ warnings.append(f"rmtree .c3 failed: {e}")
803
+ for filename, _ in _instruction_documents_for_project():
804
+ doc = src / filename
805
+ if doc.exists():
806
+ try:
807
+ doc.unlink()
808
+ except Exception as e:
809
+ warnings.append(f"delete {filename} failed: {e}")
810
+ except Exception as e:
811
+ warnings.append(f"cleanup helpers unavailable: {e}")
812
+ self.remove_project(str(src))
813
+
814
+ result = {
815
+ "merged": True,
816
+ "source": str(src),
817
+ "target": str(tgt),
818
+ "cleanup": cleanup,
819
+ "stats": stats,
820
+ }
821
+ if warnings:
822
+ result["warnings"] = warnings
823
+ return result