claude-code-session-sync 0.1.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.
@@ -0,0 +1,143 @@
1
+ """SessionShape:把一檔算成「身分集合 + multiset + 順序 + root-set + genuine leaf + active-tip」。
2
+
3
+ 依據 DESIGN 附錄 A1/A7/A11 + 附錄 B(B2 active-tip=最後一條 last-prompt.leafUuid、
4
+ leaf 排除工具 fan-out 與 sidechain;B3 root-set 含 system 根、先濾 uuid 行再判根)。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from collections import Counter
9
+ from dataclasses import dataclass
10
+
11
+ from .canonical import FileState, Line, LoadResult, load, load_bytes
12
+
13
+ # 揮發性的 session 簿記行(每個 prompt/狀態變動就變),**不算對話內容**:
14
+ # 比較同一性時要排除,否則每延伸一則就因 last-prompt 改變而被誤判成 fork。
15
+ # 內容性的 no-uuid 行(summary / isCompactSummary)不在此列,仍納入比較。
16
+ VOLATILE_META_TYPES = frozenset(
17
+ {
18
+ "last-prompt",
19
+ "mode",
20
+ "permission-mode",
21
+ "ai-title",
22
+ "custom-title",
23
+ "agent-name",
24
+ "file-history-snapshot",
25
+ }
26
+ )
27
+
28
+ # 揮發 meta 中**使用者/AI 可見的標題**:比較同一性時雖排除(每次延伸不該誤判 fork),但「ff 覆蓋對側」
29
+ # 時不可**靜默丟棄**(codex r19)——覆蓋會丟失對側既有標題 → 須交人決策,不自動套用。
30
+ PRESERVE_META_TYPES = frozenset({"ai-title", "custom-title"})
31
+
32
+
33
+ def _counts_for_compare(line: Line) -> bool:
34
+ """此行是否納入「比較用」多重集合:uuid 行一律算;no-uuid 行只算非揮發性 meta。"""
35
+ return bool(line.uuid) or (line.type not in VOLATILE_META_TYPES)
36
+
37
+
38
+ @dataclass
39
+ class SessionShape:
40
+ state: FileState
41
+ lines: list[Line] # 全部 ok 行(依序)
42
+ uuids: set[str] # 出現過的 uuid
43
+ multiset: Counter # 全部行身分 -> 次數(含揮發 meta,供 debug/union)
44
+ compare_multiset: Counter # 比較用多重集合(排除揮發 meta,A1+H2)
45
+ order: list[tuple] # 行身分依檔序
46
+ parent_map: dict[str, str | None] # uuid -> parentUuid
47
+ roots: list[Line] # 對話根(uuid 行、parent 為 null 或檔內找不到)
48
+ genuine_leaves: list[Line] # 真 tip 候選(排除 fan-out / sidechain)
49
+ active_tip: str | None # 最後一條 last-prompt.leafUuid
50
+ same_uuid_diff: set[str] # 同檔內同 uuid 不同 hash(舊行被改寫 → damaged 訊號)
51
+ uuid_hashes: dict[str, set[str]] # uuid -> 該檔出現過的 canon_hash 集(供跨檔改寫偵測)
52
+ has_bad: bool
53
+
54
+ @property
55
+ def is_damaged(self) -> bool:
56
+ """檔級狀態壞、有壞 JSON 行、或同 uuid 不同 hash → damaged。"""
57
+ return self.state.is_damaged or self.has_bad or bool(self.same_uuid_diff)
58
+
59
+ @property
60
+ def is_empty(self) -> bool:
61
+ return len(self.lines) == 0
62
+
63
+ @property
64
+ def newest_genuine_leaf(self) -> Line | None:
65
+ """genuine leaf 中 timestamp 最晚者(給 active-tip 交叉驗用)。"""
66
+ leaves = [ln for ln in self.genuine_leaves if ln.ts]
67
+ if not leaves:
68
+ return self.genuine_leaves[-1] if self.genuine_leaves else None
69
+ return max(leaves, key=lambda ln: ln.ts)
70
+
71
+
72
+ def is_ancestor(shape: SessionShape, anc: str | None, desc: str | None) -> bool:
73
+ """anc 是否為 desc 的祖先(含相等)。沿 parent_map 從 desc 往上走。"""
74
+ if anc is None or desc is None:
75
+ return False
76
+ seen: set[str] = set()
77
+ cur: str | None = desc
78
+ while cur is not None and cur not in seen:
79
+ if cur == anc:
80
+ return True
81
+ seen.add(cur)
82
+ cur = shape.parent_map.get(cur)
83
+ return False
84
+
85
+
86
+ def analyze_result(res: LoadResult) -> SessionShape:
87
+ ok = res.ok_lines
88
+ uuids = {ln.uuid for ln in ok if ln.uuid}
89
+
90
+ multiset: Counter = Counter(ln.identity for ln in ok)
91
+ compare_multiset: Counter = Counter(ln.identity for ln in ok if _counts_for_compare(ln))
92
+ order = [ln.identity for ln in ok]
93
+
94
+ parent_map: dict[str, str | None] = {}
95
+ by_uuid_hashes: dict[str, set[str]] = {}
96
+ for ln in ok:
97
+ if ln.uuid:
98
+ parent_map[ln.uuid] = ln.parent
99
+ if ln.canon_hash is not None:
100
+ by_uuid_hashes.setdefault(ln.uuid, set()).add(ln.canon_hash)
101
+ same_uuid_diff = {u for u, hs in by_uuid_hashes.items() if len(hs) > 1}
102
+
103
+ # 對話根 = uuid 行且 parent 為 null 或 parent 不在檔內(含 compact 產生的 system 根)。
104
+ roots = [ln for ln in ok if ln.uuid and (ln.parent is None or ln.parent not in uuids)]
105
+
106
+ # genuine leaf = uuid 未被任何行當 parent,且非 sidechain、非工具 fan-out。
107
+ used_as_parent = {ln.parent for ln in ok if ln.parent}
108
+ genuine_leaves = [
109
+ ln
110
+ for ln in ok
111
+ if ln.uuid and ln.uuid not in used_as_parent and not ln.is_sidechain and not ln.is_tool_fanout
112
+ ]
113
+
114
+ # active tip = 最後一條 last-prompt 行的 leafUuid。
115
+ active_tip: str | None = None
116
+ for ln in ok:
117
+ if ln.type == "last-prompt" and isinstance(ln.obj, dict) and ln.obj.get("leafUuid"):
118
+ active_tip = ln.obj["leafUuid"]
119
+
120
+ return SessionShape(
121
+ state=res.state,
122
+ lines=ok,
123
+ uuids=uuids,
124
+ multiset=multiset,
125
+ compare_multiset=compare_multiset,
126
+ order=order,
127
+ parent_map=parent_map,
128
+ roots=roots,
129
+ genuine_leaves=genuine_leaves,
130
+ active_tip=active_tip,
131
+ same_uuid_diff=same_uuid_diff,
132
+ uuid_hashes=by_uuid_hashes,
133
+ has_bad=res.has_bad,
134
+ )
135
+
136
+
137
+ def analyze(path: str) -> SessionShape:
138
+ return analyze_result(load(path))
139
+
140
+
141
+ def analyze_bytes(data: bytes) -> SessionShape:
142
+ """從 bytes 算 SessionShape(不碰檔案)。供 transfer 把寫出的 bytes 綁定到分類決策。"""
143
+ return analyze_result(load_bytes(data))