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 +5 -0
- cli/hub.html +128 -3
- cli/hub_server.py +23 -0
- {code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/METADATA +1 -1
- {code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/RECORD +10 -10
- services/project_manager.py +244 -0
- {code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/top_level.txt +0 -0
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 & 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)}')">▶ 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.
|
|
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=
|
|
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=
|
|
18
|
-
cli/hub_server.py,sha256=
|
|
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.
|
|
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
|
|
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.
|
|
147
|
-
code_context_control-2.
|
|
148
|
-
code_context_control-2.
|
|
149
|
-
code_context_control-2.
|
|
150
|
-
code_context_control-2.
|
|
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,,
|
services/project_manager.py
CHANGED
|
@@ -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
|
|
File without changes
|
{code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.28.3.dist-info → code_context_control-2.29.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|