kotools 2026.7.14__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.
Files changed (58) hide show
  1. ko/__init__.py +1 -0
  2. ko/_cli_shared.py +81 -0
  3. ko/agents/__init__.py +27 -0
  4. ko/agents/_files.py +200 -0
  5. ko/agents/_memory.py +170 -0
  6. ko/agents/_shared.py +116 -0
  7. ko/agents/_toolsets.py +307 -0
  8. ko/agents/ai.py +58 -0
  9. ko/agents/repo.py +65 -0
  10. ko/agents/research.py +52 -0
  11. ko/agents/tv.py +48 -0
  12. ko/arxiv.py +121 -0
  13. ko/billing.py +56 -0
  14. ko/brief.py +131 -0
  15. ko/cli.py +557 -0
  16. ko/cli_ai.py +895 -0
  17. ko/cli_google.py +1001 -0
  18. ko/cli_web.py +941 -0
  19. ko/config.py +88 -0
  20. ko/dirs.py +42 -0
  21. ko/doc.py +30 -0
  22. ko/exa.py +103 -0
  23. ko/fetch.py +242 -0
  24. ko/gcal.py +261 -0
  25. ko/gdocs.py +238 -0
  26. ko/gdrive.py +247 -0
  27. ko/gmail.py +166 -0
  28. ko/google_auth.py +267 -0
  29. ko/gsheets.py +704 -0
  30. ko/hf.py +129 -0
  31. ko/hn.py +184 -0
  32. ko/llm.py +202 -0
  33. ko/logs.py +136 -0
  34. ko/mcp_client.py +437 -0
  35. ko/mcp_server.py +118 -0
  36. ko/papers.py +291 -0
  37. ko/prompt.py +91 -0
  38. ko/prompts/ko-publish-site.md +47 -0
  39. ko/prompts/local-first-python-app.md +153 -0
  40. ko/prompts/opencode.md +61 -0
  41. ko/prompts/pydantic-ai-agent.md +190 -0
  42. ko/prompts/python-cli.md +140 -0
  43. ko/prompts/research-papers.md +82 -0
  44. ko/prompts/sveltekit-app.md +56 -0
  45. ko/prompts/sveltekit-embedded-ai.md +135 -0
  46. ko/publish.py +1223 -0
  47. ko/refs.py +259 -0
  48. ko/sessions.py +347 -0
  49. ko/telemetry.py +80 -0
  50. ko/ticktick.py +109 -0
  51. ko/tmdb.py +121 -0
  52. ko/x.py +300 -0
  53. ko/yt.py +151 -0
  54. kotools-2026.7.14.dist-info/METADATA +252 -0
  55. kotools-2026.7.14.dist-info/RECORD +58 -0
  56. kotools-2026.7.14.dist-info/WHEEL +4 -0
  57. kotools-2026.7.14.dist-info/entry_points.txt +3 -0
  58. kotools-2026.7.14.dist-info/licenses/LICENSE +674 -0
ko/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "2026.7.14"
ko/_cli_shared.py ADDED
@@ -0,0 +1,81 @@
1
+ """Shared CLI root app and cross-group helper functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from typing import NoReturn
8
+
9
+ import typer
10
+
11
+ # Last error label from _die() — picked up by cli.main()'s logging so failures via the
12
+ # normal _die() path populate the `error` field. Only the programmer-set `code` label is
13
+ # stored (never the message, which may contain user queries/URLs).
14
+ _last_error: str | None = None
15
+
16
+ # The root Typer app — imported by all group modules so they can self-register.
17
+ app = typer.Typer(
18
+ help="ko — Ko's opinionated CLI: web/papers (exa, fetch, arxiv, hf, papers, hn), "
19
+ "Google (gsheets, gdocs, cal, gmail), llm/agents, publish, and more. `ko doctor` for setup.",
20
+ no_args_is_help=True,
21
+ # Agents drive ko over bash: a clean one-line error on stderr + exit 1 is parseable;
22
+ # Typer's default colorized multi-frame traceback box is not. We catch expected errors
23
+ # in each command and emit them via _die(); this kills the pretty-traceback fallback.
24
+ pretty_exceptions_enable=False,
25
+ )
26
+
27
+
28
+ def _emit_json(items: list) -> None:
29
+ """Shared --json path: list of dataclasses -> one JSON array on stdout."""
30
+ typer.echo(json.dumps([asdict(i) for i in items], default=str))
31
+
32
+
33
+ def _die(
34
+ msg: str, *, as_json: bool = False, code: str = "error", exit_code: int | None = None
35
+ ) -> NoReturn:
36
+ """Error exit: a JSON error object to stderr under --json, else plain text.
37
+ Exit code follows AGENTS.md — `code="usage"` → 2, everything else → 1 (override with
38
+ exit_code). Keeps stdout clean so a downstream `... | jq` never sees a half-message.
39
+ Records the error code label in `_last_error` so cli.main() can populate the log's
40
+ `error` field without capturing user-data from the message."""
41
+ global _last_error
42
+ _last_error = code
43
+ if as_json:
44
+ typer.echo(json.dumps({"error": msg, "code": code}), err=True)
45
+ else:
46
+ typer.echo(msg, err=True)
47
+ if exit_code is None:
48
+ exit_code = 2 if code == "usage" else 1
49
+ raise typer.Exit(exit_code)
50
+
51
+
52
+ def _no_results(note: str, as_json: bool) -> NoReturn:
53
+ """Empty-result exit (exit 0, not an error): under --json emit `[]` to stdout so the pipe
54
+ stays valid JSON; the human-readable note always goes to stderr, never stdout."""
55
+ if as_json:
56
+ typer.echo("[]")
57
+ typer.echo(note, err=True)
58
+ raise typer.Exit(0)
59
+
60
+
61
+ def _tsv_cell(value) -> str:
62
+ """One value rendered safe for a single TSV field: an embedded tab or newline would
63
+ otherwise split the field / spill the row onto the next line (silently mis-shaping the
64
+ output an agent parses with `cut -f`). Tabs → `\\t`, newlines → `\\n`; content preserved,
65
+ one row stays one line. `None` → ''."""
66
+ if value is None:
67
+ return ""
68
+ s = str(value).replace("\r\n", "\n").replace("\r", "\n")
69
+ return s.replace("\t", "\\t").replace("\n", "\\n")
70
+
71
+
72
+ def _fmt_day(iso: str | None) -> str:
73
+ """An ISO date/datetime string -> human '22 Jun 2018' (day only, no time); '' if missing."""
74
+ if not iso:
75
+ return ""
76
+ from datetime import datetime
77
+
78
+ try:
79
+ return datetime.fromisoformat(iso.replace("Z", "+00:00")).strftime("%d %b %Y")
80
+ except ValueError:
81
+ return iso[:10] # fall back to the date portion
ko/agents/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ from ko.agents.ai import agent as ai_agent
2
+ from ko.agents.ai import repl as ai_repl
3
+ from ko.agents.ai import run as ai_run
4
+ from ko.agents.repo import agent as repo_agent
5
+ from ko.agents.repo import repl as repo_repl
6
+ from ko.agents.repo import run as repo_run
7
+ from ko.agents.research import agent as research_agent
8
+ from ko.agents.research import repl as research_repl
9
+ from ko.agents.research import run as research_run
10
+ from ko.agents.tv import agent as tv_agent
11
+ from ko.agents.tv import repl as tv_repl
12
+ from ko.agents.tv import run as tv_run
13
+
14
+ __all__ = [
15
+ "ai_agent",
16
+ "ai_run",
17
+ "ai_repl",
18
+ "repo_agent",
19
+ "repo_run",
20
+ "repo_repl",
21
+ "research_agent",
22
+ "research_run",
23
+ "research_repl",
24
+ "tv_agent",
25
+ "tv_run",
26
+ "tv_repl",
27
+ ]
ko/agents/_files.py ADDED
@@ -0,0 +1,200 @@
1
+ """Read-only file tools for the repo-explorer agent — confined to ~/code.
2
+
3
+ Read-only **by construction**: no write/edit tools exist at all — stronger than
4
+ guarding writes (nothing to misuse, nothing in the model's tool list to waste
5
+ tokens on). Containment ported from thinker's `resolve_in` (resolve BOTH sides,
6
+ then `is_relative_to` — symlinks resolved before the check, the same TOCTTOU
7
+ ordering pydantic-ai-harness uses). Search shells out to ripgrep.
8
+
9
+ Every tool returns small, capped output (a cheap model drowns in dumps):
10
+ line-capped reads with continuation pointers, result-capped grep/find,
11
+ binary detection, hidden dirs (.git etc.) skipped everywhere.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from pydantic_ai import ModelRetry
20
+
21
+ from ko import config
22
+
23
+ MAX_READ_LINES = 400
24
+ MAX_READ_BYTES = 1_000_000 # refuse whole-file reads beyond this — grep instead
25
+ MAX_LINE_CHARS = 500 # a minified one-liner must not blow the context
26
+ MAX_GREP_LINES = 100
27
+ MAX_LIST_ENTRIES = 200
28
+ MAX_FIND_RESULTS = 200
29
+ OVERVIEW_HEAD_LINES = 150 # refs/CLAUDE.md head injected into instructions
30
+
31
+
32
+ def code_root() -> Path:
33
+ """The explorable root: KO_CODE_DIR → `[code] dir` in config.toml → ~/code."""
34
+ return Path(
35
+ config.setting("KO_CODE_DIR", "code", "dir", str(Path.home() / "code"))
36
+ ).expanduser()
37
+
38
+
39
+ def _resolve(path: str) -> Path:
40
+ """THE containment guard — every path-taking tool routes through here.
41
+
42
+ Accepts paths relative to the code root ('refs/llm/setup.py') or absolute
43
+ ones that land inside it. Both sides are fully resolved (symlinks followed)
44
+ BEFORE the containment check, so a symlink pointing outside can't escape."""
45
+ root = code_root().resolve()
46
+ p = Path(path).expanduser()
47
+ full = (p if p.is_absolute() else root / p).resolve()
48
+ if not full.is_relative_to(root):
49
+ raise ModelRetry(
50
+ f"'{path}' is outside {root} — this agent only reads inside it. "
51
+ f"Use paths relative to that root, e.g. 'everx' or 'refs/pydantic-ai'."
52
+ )
53
+ if any(part.startswith(".") for part in full.relative_to(root).parts):
54
+ raise ModelRetry(
55
+ f"'{path}' is under a hidden directory (.git etc.) — those are off-limits."
56
+ )
57
+ return full
58
+
59
+
60
+ def list_dir(path: str = ".") -> str:
61
+ """Entries in a directory: dirs first with a trailing '/', file sizes shown.
62
+ Hidden entries skipped; capped."""
63
+ d = _resolve(path)
64
+ if not d.is_dir():
65
+ raise ModelRetry(f"'{path}' is not a directory. Try read_file, or list its parent.")
66
+ entries = sorted(
67
+ (p for p in d.iterdir() if not p.name.startswith(".")),
68
+ key=lambda p: (p.is_file(), p.name.lower()),
69
+ )
70
+ def _entry(e: Path) -> str:
71
+ if e.is_dir():
72
+ return f"{e.name}/"
73
+ try:
74
+ size = e.stat().st_size
75
+ except OSError: # broken symlink — stat follows the (dead) target
76
+ return f"{e.name} (broken symlink)"
77
+ # ≈tokens (bytes/4) so the model can judge "read it whole" vs "grep first"
78
+ return f"{e.name} ({size:,}B ≈{size // 4:,} tok)"
79
+
80
+ lines = [_entry(e) for e in entries[:MAX_LIST_ENTRIES]]
81
+ if len(entries) > MAX_LIST_ENTRIES:
82
+ lines.append(f"[+{len(entries) - MAX_LIST_ENTRIES} more entries]")
83
+ return "\n".join(lines) or "(empty)"
84
+
85
+
86
+ def read_file(path: str, offset: int = 0, limit: int = MAX_READ_LINES) -> str:
87
+ """A file's text with line numbers (cite as file:line). Line-capped; call again
88
+ with `offset` to continue. Binary files return a placeholder, missing ones a
89
+ listing of what IS there (thinker's self-correction pattern)."""
90
+ f = _resolve(path)
91
+ if not f.is_file():
92
+ parent = f.parent
93
+ hint = list_dir(str(parent.relative_to(code_root().resolve()) or ".")) if parent.is_dir() else "(parent missing too)"
94
+ raise ModelRetry(f"'{path}' does not exist. In its directory:\n{hint}")
95
+ size = f.stat().st_size
96
+ if size > MAX_READ_BYTES:
97
+ return (
98
+ f"(file is {size:,} bytes ≈{size // 4:,} tokens — too large to read whole; "
99
+ f"grep it to locate the relevant lines instead)"
100
+ )
101
+ raw = f.read_bytes()
102
+ if b"\x00" in raw[:8192]:
103
+ return f"(binary file, {len(raw):,} bytes — not readable as text)"
104
+ lines = raw.decode("utf-8", errors="replace").splitlines()
105
+ window = [
106
+ ln if len(ln) <= MAX_LINE_CHARS else ln[:MAX_LINE_CHARS] + "…[line truncated]"
107
+ for ln in lines[offset : offset + limit]
108
+ ]
109
+ out = [f"{offset + i + 1}: {ln}" for i, ln in enumerate(window)]
110
+ remaining = len(lines) - offset - len(window) # window == the emitted slice
111
+ if remaining > 0:
112
+ out.append(f"[+{remaining} more lines — call read_file(path, offset={offset + limit})]")
113
+ return "\n".join(out) or "(empty file)"
114
+
115
+
116
+ def grep(
117
+ pattern: str,
118
+ path: str = ".",
119
+ glob: str | None = None,
120
+ limit: int = MAX_GREP_LINES,
121
+ context: int = 0,
122
+ files_only: bool = False,
123
+ ) -> str:
124
+ """Regex search file CONTENTS via ripgrep (smart-case, respects .gitignore).
125
+ Thin rg passthrough behind the containment guard — params mirror Claude Code's
126
+ Grep (glob filter, context lines, files-with-matches mode) so models get the
127
+ shapes they were trained on. `file:line: text`, at most `limit` matches."""
128
+ d = _resolve(path)
129
+ limit = max(1, min(limit, 2 * MAX_GREP_LINES))
130
+ if files_only:
131
+ cmd = ["rg", "-l", "-S"]
132
+ else:
133
+ cmd = ["rg", "-n", "--no-heading", "-S", "--max-columns", "300"]
134
+ if context:
135
+ cmd += ["-C", str(min(context, 10))]
136
+ if glob:
137
+ cmd += ["-g", glob]
138
+ cmd += [pattern, str(d)]
139
+ try:
140
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
141
+ except FileNotFoundError:
142
+ raise ModelRetry("ripgrep (rg) is not installed on this machine — use find_files + read_file instead.")
143
+ except subprocess.TimeoutExpired:
144
+ raise ModelRetry("search timed out (30s) — scope to a subdirectory or add a glob.")
145
+ if proc.returncode not in (0, 1): # 1 = no matches; anything else is a real error
146
+ raise ModelRetry(f"search failed: {proc.stderr.strip()[:200]}")
147
+ root = str(code_root().resolve()) + "/"
148
+ lines = [ln.replace(root, "", 1) for ln in proc.stdout.splitlines()]
149
+ if not lines:
150
+ return "(no matches)"
151
+ out = lines[:limit]
152
+ if len(lines) > limit:
153
+ out.append(
154
+ f"[+{len(lines) - limit} more matches — scope to a subdirectory, add a glob, or tighten the pattern]"
155
+ )
156
+ return "\n".join(out)
157
+
158
+
159
+ def glob(pattern: str, path: str = ".") -> str:
160
+ """Find files by NAME glob pattern (e.g. '*.py', '**/test_*.ts') via `rg --files`
161
+ (respects .gitignore; hidden dirs like .git/.venv and installed packages are
162
+ excluded). Capped; paths relative to the code root. (Named/shaped like Claude
163
+ Code's Glob tool — the signature models are trained on.)"""
164
+ d = _resolve(path)
165
+ try:
166
+ proc = subprocess.run(
167
+ ["rg", "--files", "-g", pattern, str(d)], capture_output=True, text=True, timeout=30
168
+ )
169
+ except FileNotFoundError:
170
+ raise ModelRetry("ripgrep (rg) is not installed — use list_dir to navigate instead.")
171
+ except subprocess.TimeoutExpired:
172
+ raise ModelRetry("find timed out (30s) — scope to a subdirectory.")
173
+ if proc.returncode not in (0, 1):
174
+ raise ModelRetry(f"find failed: {proc.stderr.strip()[:200]}")
175
+ root = str(code_root().resolve()) + "/"
176
+ lines = [ln.replace(root, "", 1) for ln in proc.stdout.splitlines()]
177
+ if not lines:
178
+ return "(no files match)"
179
+ out = lines[:MAX_FIND_RESULTS]
180
+ if len(lines) > MAX_FIND_RESULTS:
181
+ out.append(f"[+{len(lines) - MAX_FIND_RESULTS} more — narrow the glob or path]")
182
+ return "\n".join(out)
183
+
184
+
185
+ def refs_overview_head() -> str:
186
+ """The first OVERVIEW_HEAD_LINES of refs/CLAUDE.md (the curated map of the
187
+ reference repos + accumulated takeaways), with a pointer to the rest —
188
+ thinker's memory-head pattern. Injected into the repo agent's instructions."""
189
+ from ko import refs
190
+
191
+ f = refs.claude_md(refs.refs_dir())
192
+ try:
193
+ lines = f.read_text().splitlines()
194
+ except OSError:
195
+ return "(no refs/CLAUDE.md yet — run `ko refs setup`)"
196
+ head = lines[:OVERVIEW_HEAD_LINES]
197
+ if len(lines) > OVERVIEW_HEAD_LINES:
198
+ rel = f.resolve().relative_to(code_root().resolve())
199
+ head.append(f"[+{len(lines) - OVERVIEW_HEAD_LINES} more lines — read_file('{rel}')]")
200
+ return "\n".join(head)
ko/agents/_memory.py ADDED
@@ -0,0 +1,170 @@
1
+ """Per-agent markdown memory — a writable workspace each agent maintains itself.
2
+
3
+ Design (docs/memory.md "v1 first", researched against thinker/deepagents/hermes):
4
+ each agent gets `~/.local/state/ko/memory/<agent>/` — `memory.md` is the anchor
5
+ (head-injected into instructions every run), and the agent may grow other .md
6
+ notes beside it (read on demand; the injection lists them). One shared
7
+ `~/.config/ko/memory.md` (user preferences / standing context, dotfile-synced)
8
+ is injected into every memory-carrying agent first; v1 keeps it hand-edited.
9
+
10
+ Guardrails: tools are scoped to the agent's OWN dir, .md files only (this is a
11
+ notes space, not a filesystem); `memory.md` can't be wholesale overwritten —
12
+ append or uniqueness-guarded edit only (thinker's invariant). The pin marker
13
+ (deepagents): lines above `<!-- ko:pin-end -->` always survive injection; the
14
+ tail below is recency-truncated (newest kept), so foundations never age out.
15
+
16
+ Curation lives in the toolset INSTRUCTIONS, not code — thinker ran 8 months of
17
+ agent-curated memories this way without bloat.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ from pydantic_ai import ModelRetry
25
+ from pydantic_ai.toolsets import FunctionToolset
26
+
27
+ from ko.dirs import config_dir, state_dir
28
+
29
+ MEMORY_FILE = "memory.md"
30
+ PIN_MARKER = "<!-- ko:pin-end -->"
31
+ HEAD_LINES = 200 # max memory lines injected into instructions
32
+
33
+
34
+ def memory_dir(agent: str) -> Path:
35
+ if Path(agent).name != agent or agent.startswith("."): # factory is public API — keep it honest
36
+ raise ValueError(f"agent name must be a plain name, got {agent!r}")
37
+ d = state_dir() / "memory" / agent
38
+ d.mkdir(parents=True, exist_ok=True)
39
+ return d
40
+
41
+
42
+ def shared_file() -> Path:
43
+ return config_dir() / "memory.md"
44
+
45
+
46
+ def _resolve_note(agent: str, name: str) -> Path:
47
+ """Containment for the workspace: the agent's own memory dir is FLAT — plain
48
+ .md names only (subdir names used to escape into an uncaught FileNotFoundError,
49
+ and wouldn't show in the injection listing anyway)."""
50
+ if Path(name).name != name:
51
+ raise ModelRetry(f"'{name}' — use a plain name like 'memory.md' or 'notes.md' (no folders).")
52
+ root = memory_dir(agent).resolve()
53
+ full = (root / name).resolve()
54
+ if not full.is_relative_to(root):
55
+ raise ModelRetry(f"'{name}' is outside your memory folder — use plain names like 'memory.md' or 'notes.md'.")
56
+ if full.suffix.lower() != ".md":
57
+ raise ModelRetry(f"'{name}' — your memory folder holds markdown only; use a .md name.")
58
+ return full
59
+
60
+
61
+ def _notes(agent: str) -> list[str]:
62
+ root = memory_dir(agent)
63
+ return sorted(p.name for p in root.glob("*.md"))
64
+
65
+
66
+ def _head(text: str, budget: int = HEAD_LINES, hint: str | None = f"read_note('{MEMORY_FILE}') for all") -> str:
67
+ """Pin-aware head: pinned lines always included; the tail keeps its NEWEST lines
68
+ (entries append at the bottom) with a truncation marker pointing at the rest.
69
+ `hint=None` for files no tool can read back (the shared memory)."""
70
+ lines = text.splitlines()
71
+ if len(lines) <= budget:
72
+ return text
73
+ if PIN_MARKER in text:
74
+ cut = next(i for i, ln in enumerate(lines) if PIN_MARKER in ln) + 1
75
+ pinned, tail = lines[:cut], lines[cut:]
76
+ else:
77
+ pinned, tail = [], lines
78
+ keep = max(budget - len(pinned), 0)
79
+ dropped = len(tail) - keep
80
+ if dropped <= 0: # everything pinned / nothing to drop — no noise marker
81
+ return text
82
+ marker = [f"[…{dropped} older lines truncated{' — ' + hint if hint else ''}]"]
83
+ return "\n".join(pinned + marker + tail[-keep:] if keep else pinned + marker)
84
+
85
+
86
+ def instructions_block(agent: str) -> str:
87
+ """The memory injection for an agent's dynamic instructions: shared memory,
88
+ then the agent's own memory.md head, then a listing of its other notes."""
89
+ parts: list[str] = []
90
+ try:
91
+ shared = shared_file().read_text(errors="replace").strip()
92
+ if shared:
93
+ # hint=None: no tool reads the shared file — a read_note hint would misdirect
94
+ parts.append(f"## Shared memory (about Ko — {shared_file()})\n\n{_head(shared, hint=None)}")
95
+ except OSError:
96
+ pass
97
+ own = memory_dir(agent) / MEMORY_FILE
98
+ try:
99
+ text = own.read_text(errors="replace").strip()
100
+ except OSError:
101
+ text = ""
102
+ parts.append(
103
+ f"## Your memory ({MEMORY_FILE})\n\n{_head(text) if text else '(empty — you have not saved anything yet)'}"
104
+ )
105
+ others = [n for n in _notes(agent) if n != MEMORY_FILE]
106
+ if others:
107
+ parts.append("Your other notes (read_note to open): " + ", ".join(others))
108
+ return "\n\n".join(parts)
109
+
110
+
111
+ def memory_toolset(agent: str) -> FunctionToolset:
112
+ """The write-capable workspace toolset for ONE agent — a factory, not a shared
113
+ instance (tools close over the agent's own dir)."""
114
+ ts = FunctionToolset(
115
+ instructions=(
116
+ f"Memory: you own the folder behind memory.md (shown above) and may keep other "
117
+ f".md notes in it. Curation rules — update SLOWLY: save only what will recur; "
118
+ f"bump dates/counts in-place with edit_note; DELETE stale entries; keep "
119
+ f"{MEMORY_FILE} under ~100 lines (put durable foundations above the "
120
+ f"'{PIN_MARKER}' line — they always survive truncation). Topic findings go in "
121
+ f"your own notes, never in the shared memory."
122
+ )
123
+ )
124
+
125
+ @ts.tool_plain
126
+ def read_note(name: str = MEMORY_FILE) -> str:
127
+ """Read one of your memory-folder notes in full (default: memory.md)."""
128
+ f = _resolve_note(agent, name)
129
+ if not f.is_file():
130
+ have = ", ".join(_notes(agent)) or "(none yet)"
131
+ raise ModelRetry(f"'{name}' does not exist. Your notes: {have}")
132
+ return f.read_text()
133
+
134
+ @ts.tool_plain
135
+ def append_memory(text: str) -> str:
136
+ """Append lines to the BOTTOM of memory.md (newest last — the tail is what
137
+ survives truncation). For changing existing lines use edit_note."""
138
+ f = _resolve_note(agent, MEMORY_FILE)
139
+ with f.open("a") as fh:
140
+ fh.write(text.rstrip() + "\n") # every append ends the file with \n — no separator needed
141
+ return f"appended {len(text.splitlines())} line(s) to {MEMORY_FILE}"
142
+
143
+ @ts.tool_plain
144
+ def write_note(name: str, content: str) -> str:
145
+ """Create or overwrite one of your notes (NOT memory.md — that anchor only
146
+ changes via append_memory/edit_note, so it can't be clobbered wholesale)."""
147
+ f = _resolve_note(agent, name)
148
+ if f.name.lower() == MEMORY_FILE: # .lower(): APFS is case-insensitive — 'MEMORY.MD' IS the anchor
149
+ raise ModelRetry("memory.md can't be overwritten — append_memory or edit_note it.")
150
+ f.write_text(content.rstrip() + "\n")
151
+ return f"wrote {len(content.splitlines())} line(s) to {name}"
152
+
153
+ @ts.tool_plain
154
+ def edit_note(old_string: str, new_string: str, name: str = MEMORY_FILE) -> str:
155
+ """Replace an exact string in a note (default memory.md). `old_string` must
156
+ occur exactly once — include enough surrounding context to make it unique.
157
+ Use with new_string='' to delete stale entries."""
158
+ f = _resolve_note(agent, name)
159
+ if not f.is_file():
160
+ raise ModelRetry(f"'{name}' does not exist yet.")
161
+ text = f.read_text()
162
+ n = text.count(old_string)
163
+ if n == 0:
164
+ raise ModelRetry(f"string not found in {name} — read_note it and retry with the exact text.")
165
+ if n > 1:
166
+ raise ModelRetry(f"string occurs {n} times in {name} — add surrounding context to make it unique.")
167
+ f.write_text(text.replace(old_string, new_string, 1))
168
+ return "edit applied"
169
+
170
+ return ts
ko/agents/_shared.py ADDED
@@ -0,0 +1,116 @@
1
+ """Shared run/stream/repl helpers — generic over any pydantic-ai Agent.
2
+
3
+ Each ko agent module builds its `Agent` and binds these (`run(agent, prompt, name=...)`),
4
+ so streaming, pipe handling, the REPL loop, model override, and session saving all
5
+ live in one place. Every turn is persisted via `ko.sessions` so any agent gets
6
+ resume + history for free.
7
+
8
+ `model` is passed per-run (not baked into the Agent) so the `-m` flag actually
9
+ takes effect — the Agent reads its default model at import, before the CLI runs.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import readline # noqa: F401 — enables up-arrow history + line editing in repl
15
+
16
+ from pydantic_ai import Agent
17
+ from rich.console import Console
18
+ from rich.live import Live
19
+ from rich.markdown import Markdown
20
+
21
+ from ko import sessions
22
+ from ko.llm import run_cost
23
+
24
+ _console = Console()
25
+ _err = Console(stderr=True) # cost notes are notes, not data — stderr keeps pipes clean
26
+
27
+
28
+ def _cost_note(messages: list, prior: int = 0) -> None:
29
+ """Print what the new messages (beyond `prior`) cost — OR actuals when available.
30
+ markup=False: the note's own [brackets] would otherwise parse as rich tags and
31
+ silently swallow the whole line."""
32
+ _err.print(run_cost(messages[prior:]).note, style="dim", markup=False, highlight=False)
33
+
34
+
35
+ def _stream(
36
+ agent: Agent, prompt: str, history: list, model: str | None = None, limits=None
37
+ ) -> tuple[str, list]:
38
+ """Run one turn with live markdown output. Returns (text, full message history).
39
+
40
+ v2: run_stream_sync returns the result directly (not a context manager), and
41
+ stream_text(delta=False) yields the full text so far each tick.
42
+ """
43
+ result = agent.run_stream_sync(prompt, message_history=history, model=model, usage_limits=limits)
44
+ text = ""
45
+ with Live(console=_console, refresh_per_second=15) as live:
46
+ for text in result.stream_text():
47
+ live.update(Markdown(text))
48
+ return text, result.all_messages()
49
+
50
+
51
+ def run(
52
+ agent: Agent,
53
+ prompt: str,
54
+ name: str = "agent",
55
+ model: str | None = None,
56
+ resume: str | None = None,
57
+ limits=None,
58
+ ) -> str:
59
+ """One-shot. Streams pretty markdown to a TTY; plain text when piped. Saves the session.
60
+
61
+ `resume` continues a saved session (the prompt becomes its next turn) instead of
62
+ starting fresh — so `ko a research "follow-up" -r <id>` works as a one-shot continuation.
63
+ """
64
+ session_id = resume or sessions.new_id()
65
+ history = sessions.load(resume) if resume else []
66
+ if _console.is_terminal:
67
+ text, messages = _stream(agent, prompt, history, model=model, limits=limits)
68
+ else:
69
+ result = agent.run_sync(prompt, message_history=history, model=model, usage_limits=limits)
70
+ text, messages = result.output, result.all_messages()
71
+ print(text)
72
+ _cost_note(messages, prior=len(history))
73
+ sessions.save(session_id, name, messages)
74
+ return text
75
+
76
+
77
+ def repl(
78
+ agent: Agent,
79
+ banner: str = "agent",
80
+ name: str | None = None,
81
+ model: str | None = None,
82
+ resume: str | None = None,
83
+ limits=None,
84
+ ) -> None:
85
+ """Interactive session; history preserved across turns and saved after each.
86
+
87
+ `resume` = a session id to continue an earlier conversation.
88
+ """
89
+ name = name or banner
90
+ if resume:
91
+ session_id = resume
92
+ history = sessions.load(resume)
93
+ _console.print(f"[dim]resumed session {resume} ({len(history)} messages)[/dim]")
94
+ else:
95
+ session_id = sessions.new_id()
96
+ history = []
97
+ _console.print(f"[dim]ko {banner} — /exit to quit, /clear to reset context[/dim]")
98
+ while True:
99
+ try:
100
+ user_input = input(f"{banner}> ").strip()
101
+ except (EOFError, KeyboardInterrupt):
102
+ print()
103
+ break
104
+ if not user_input:
105
+ continue
106
+ if user_input == "/exit":
107
+ break
108
+ if user_input == "/clear":
109
+ history = []
110
+ session_id = sessions.new_id()
111
+ _console.print("[dim]context cleared (new session)[/dim]")
112
+ continue
113
+ prior = len(history)
114
+ _, history = _stream(agent, user_input, history, model=model, limits=limits)
115
+ _cost_note(history, prior=prior)
116
+ sessions.save(session_id, name, history)