codex-cissor 0.2.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.
File without changes
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from .config import state_dir
8
+
9
+
10
+ def activity_path() -> Path:
11
+ return state_dir() / "activity.jsonl"
12
+
13
+
14
+ def log_event(kind: str, session=None, **fields) -> None:
15
+ p = activity_path()
16
+ p.parent.mkdir(parents=True, exist_ok=True)
17
+ rec = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "kind": kind}
18
+ if session is not None:
19
+ rec["session"] = str(session)
20
+ for k, v in fields.items():
21
+ if v is not None:
22
+ rec[k] = v
23
+ with open(p, "a", encoding="utf-8") as f:
24
+ f.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
25
+
26
+
27
+ def read_events(limit: int = 50, session: str | None = None, channel: str | None = None, project: str | None = None) -> list[dict]:
28
+ p = activity_path()
29
+ if not p.exists():
30
+ return []
31
+ out: list[dict] = []
32
+ with open(p, "r", encoding="utf-8") as f:
33
+ for line in f:
34
+ line = line.strip()
35
+ if not line:
36
+ continue
37
+ try:
38
+ rec = json.loads(line)
39
+ except json.JSONDecodeError:
40
+ continue
41
+ if session and session not in (rec.get("session") or ""):
42
+ continue
43
+ if channel and channel != rec.get("channel") and channel not in str(rec.get("chain") or ""):
44
+ continue
45
+ if project and project not in (rec.get("project") or ""):
46
+ continue
47
+ out.append(rec)
48
+ return out[-limit:]
49
+
50
+
51
+ def stats(events: list[dict]) -> dict:
52
+ from collections import Counter
53
+
54
+ by_kind = Counter(e.get("kind", "?") for e in events)
55
+ saved = sum(
56
+ (e.get("tokens_before") or 0) - (e.get("tokens_after") or 0)
57
+ for e in events
58
+ if e.get("kind") == "edit"
59
+ )
60
+ return {
61
+ "events": len(events),
62
+ "by_kind": dict(by_kind),
63
+ "tokens_saved_total": saved,
64
+ "sessions_touched": len({e.get("session") for e in events if e.get("session")}),
65
+ "channels": sorted({e["channel"] for e in events if e.get("channel")}),
66
+ "first_ts": events[0].get("ts") if events else None,
67
+ "last_ts": events[-1].get("ts") if events else None,
68
+ }
codex_cissor/asks.py ADDED
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from .rollout import Rollout, load_rollout
10
+ from .summarize import _transcript, chat
11
+ from .tokens import count_text
12
+
13
+ ASK_SYSTEM = (
14
+ "You answer questions strictly from the material provided. Cite item numbers, turn numbers, or line "
15
+ "anchors from the material when they support the answer. If the material does not contain the answer, "
16
+ "say so plainly instead of guessing. Be dense and concrete."
17
+ )
18
+
19
+
20
+ def asks_dir() -> Path:
21
+ return state_dir_path() / "asks"
22
+
23
+
24
+ def state_dir_path() -> Path:
25
+ from .config import state_dir
26
+
27
+ return state_dir()
28
+
29
+
30
+ def _new_id() -> str:
31
+ return "a" + uuid.uuid4().hex[:10]
32
+
33
+
34
+ def save_store(store: dict) -> Path:
35
+ d = asks_dir()
36
+ d.mkdir(parents=True, exist_ok=True)
37
+ p = d / f"{store['id']}.json"
38
+ store["updated"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
39
+ p.write_text(json.dumps(store, indent=2, ensure_ascii=False), encoding="utf-8")
40
+ return p
41
+
42
+
43
+ def load_ask(ref: str) -> dict | None:
44
+ d = asks_dir()
45
+ if not d.exists():
46
+ return None
47
+ for p in sorted(d.glob("*.json")):
48
+ if p.stem == ref or p.stem.startswith(ref):
49
+ try:
50
+ return json.loads(p.read_text(encoding="utf-8"))
51
+ except Exception:
52
+ continue
53
+ return None
54
+
55
+
56
+ def list_asks() -> list[dict]:
57
+ d = asks_dir()
58
+ if not d.exists():
59
+ return []
60
+ out = []
61
+ for p in sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True):
62
+ try:
63
+ s = json.loads(p.read_text(encoding="utf-8"))
64
+ except Exception:
65
+ continue
66
+ out.append(
67
+ {
68
+ "id": s.get("id"),
69
+ "model": s.get("model"),
70
+ "turns": len(s.get("turns", [])),
71
+ "target": _target_label(s.get("target", {})),
72
+ "updated": s.get("updated"),
73
+ "last_question": (s.get("turns") or [{}])[-1].get("q", "")[:80],
74
+ }
75
+ )
76
+ return out
77
+
78
+
79
+ def _target_label(t: dict) -> str:
80
+ kind = t.get("kind", "?")
81
+ if kind == "file":
82
+ return f"file:{t.get('path', '')}"
83
+ if kind == "session":
84
+ ref = t.get("ref", "")
85
+ extra = ""
86
+ if t.get("selection"):
87
+ extra = " " + t["selection"]
88
+ return f"session:{ref}{extra} scope={t.get('scope', 'auto')}"
89
+ return kind
90
+
91
+
92
+ def resolve_file_target(path: Path) -> dict:
93
+ raw = path.read_bytes()
94
+ if path.suffix.lower() == ".pdf" or raw[:4] == b"%PDF":
95
+ raise SystemExit(
96
+ f"{path.name} is a PDF — cissor ask reads text only. Convert it to text/markdown first "
97
+ f"(e.g. with a parse tool or pdftotext), then ask the converted file."
98
+ )
99
+ if b"\x00" in raw[:8192]:
100
+ raise SystemExit(f"{path.name} looks binary; ask supports text files only (convert first if needed)")
101
+ return {"kind": "file", "path": str(path)}
102
+
103
+
104
+ def resolve_session_target(doc: Rollout, selection_spec: dict) -> dict:
105
+ return {
106
+ "kind": "session",
107
+ "ref": str(doc.path),
108
+ "session_id": doc.session_id,
109
+ "scope": selection_spec.get("scope", "auto"),
110
+ "selection": selection_spec.get("selection", ""),
111
+ }
112
+
113
+
114
+ def collect_content(target: dict, cfg: dict) -> str:
115
+ if target["kind"] == "file":
116
+ return Path(target["path"]).read_text(encoding="utf-8", errors="replace")
117
+ from .selection import resolve_selection
118
+
119
+ doc = load_rollout(target["ref"])
120
+ items = doc.build_items(cfg.get("encoding", "o200k_base"), scope=target.get("scope", "auto"))
121
+ spec = target.get("selection") or ""
122
+ if spec.strip():
123
+ kwargs = json.loads(spec)
124
+ sel = resolve_selection(doc, items, **kwargs)
125
+ items = sel.ordered_items(items)
126
+ return _transcript(items)
127
+
128
+
129
+ def content_hash(content: str) -> str:
130
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
131
+
132
+
133
+ def _chunk_text(content: str, max_chunk_tokens: int, encoding_name: str) -> list[str]:
134
+ paragraphs = content.split("\n\n")
135
+ chunks: list[str] = []
136
+ cur: list[str] = []
137
+ cur_tokens = 0
138
+ for para in paragraphs:
139
+ p_tokens = count_text(para, encoding_name)
140
+ if cur and cur_tokens + p_tokens > max_chunk_tokens:
141
+ chunks.append("\n\n".join(cur))
142
+ cur = []
143
+ cur_tokens = 0
144
+ if p_tokens > max_chunk_tokens:
145
+ if cur:
146
+ chunks.append("\n\n".join(cur))
147
+ cur = []
148
+ cur_tokens = 0
149
+ line = ""
150
+ for line_part in para.split("\n"):
151
+ if line and count_text(line + "\n" + line_part, encoding_name) > max_chunk_tokens:
152
+ chunks.append(line)
153
+ line = line_part
154
+ else:
155
+ line = (line + "\n" + line_part) if line else line_part
156
+ if line:
157
+ cur = [line]
158
+ cur_tokens = count_text(line, encoding_name)
159
+ continue
160
+ cur.append(para)
161
+ cur_tokens += p_tokens
162
+ if cur:
163
+ chunks.append("\n\n".join(cur))
164
+ return chunks
165
+
166
+
167
+ def ask_question(
168
+ store: dict,
169
+ content: str,
170
+ question: str,
171
+ cfg: dict,
172
+ model: str,
173
+ ) -> str:
174
+ store["content_hash"] = content_hash(content)
175
+
176
+ encoding_name = cfg.get("encoding", "o200k_base")
177
+ max_input = int(cfg.get("ask_max_input_tokens", 120000))
178
+ chunk_tokens = int(cfg.get("chunk_tokens", 48000))
179
+ n_tokens = count_text(content, encoding_name)
180
+ prior = []
181
+ for t in store.get("turns", []):
182
+ prior.append({"role": "user", "content": t["q"]})
183
+ prior.append({"role": "assistant", "content": t["a"]})
184
+
185
+ def _finish(answer: str) -> str:
186
+ _record_turn(store, question, answer)
187
+ return answer
188
+
189
+ if n_tokens <= max_input:
190
+ messages = [{"role": "system", "content": ASK_SYSTEM}]
191
+ messages += prior
192
+ messages.append(
193
+ {
194
+ "role": "user",
195
+ "content": f"MATERIAL ({n_tokens:,} tokens):\n\n{content}\n\nQUESTION: {question}",
196
+ }
197
+ )
198
+ return _finish(chat(cfg, model, messages))
199
+
200
+ chunks = _chunk_text(content, chunk_tokens, encoding_name)
201
+ map_notes: list[str] = []
202
+ for i, chunk in enumerate(chunks, 1):
203
+ messages = [
204
+ {"role": "system", "content": "You extract facts relevant to a question from a fragment of a larger document. Quote exact values, names, and anchors. If nothing relevant exists, say 'nothing relevant'."},
205
+ {"role": "user", "content": f"QUESTION TO ANSWER: {question}\n\nFRAGMENT {i}/{len(chunks)} of the document:\n\n{chunk}"},
206
+ ]
207
+ map_notes.append(f"## Fragment {i}/{len(chunks)}\n\n{chat(cfg, model, messages)}")
208
+
209
+ messages = [{"role": "system", "content": ASK_SYSTEM}]
210
+ messages += prior
211
+ messages.append(
212
+ {
213
+ "role": "user",
214
+ "content": (
215
+ f"The document is too large ({n_tokens:,} tokens) so it was scanned in {len(chunks)} fragments; "
216
+ f"below are the fragments' relevant extracts.\n\n"
217
+ f"--- EXTRACTS ---\n\n" + "\n\n".join(map_notes) +
218
+ f"\n\n--- ORIGINAL QUESTION ---\n{question}\n\n"
219
+ "Answer the question from these extracts. Say which fragments support each claim. "
220
+ "If the extracts don't contain the answer, say so."
221
+ ),
222
+ }
223
+ )
224
+ return _finish(chat(cfg, model, messages))
225
+
226
+
227
+ def _record_turn(store: dict, q: str, a: str) -> None:
228
+ store.setdefault("turns", []).append({"q": q, "a": a, "at": datetime.now(timezone.utc).isoformat(timespec="seconds")})