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.
- codex_cissor/__init__.py +0 -0
- codex_cissor/__main__.py +5 -0
- codex_cissor/activity.py +68 -0
- codex_cissor/asks.py +228 -0
- codex_cissor/cli.py +1074 -0
- codex_cissor/config.py +106 -0
- codex_cissor/edits.py +543 -0
- codex_cissor/export.py +186 -0
- codex_cissor/rollout.py +273 -0
- codex_cissor/selection.py +101 -0
- codex_cissor/sessions.py +288 -0
- codex_cissor/styles.py +116 -0
- codex_cissor/summarize.py +171 -0
- codex_cissor/tokens.py +49 -0
- codex_cissor/validate.py +72 -0
- codex_cissor-0.2.0.dist-info/METADATA +309 -0
- codex_cissor-0.2.0.dist-info/RECORD +21 -0
- codex_cissor-0.2.0.dist-info/WHEEL +5 -0
- codex_cissor-0.2.0.dist-info/entry_points.txt +2 -0
- codex_cissor-0.2.0.dist-info/licenses/LICENSE +21 -0
- codex_cissor-0.2.0.dist-info/top_level.txt +1 -0
codex_cissor/__init__.py
ADDED
|
File without changes
|
codex_cissor/__main__.py
ADDED
codex_cissor/activity.py
ADDED
|
@@ -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")})
|