greedy-token 0.2.1__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.
- greedy_token/__init__.py +3 -0
- greedy_token/__main__.py +4 -0
- greedy_token/cli.py +205 -0
- greedy_token/config/routes.yaml +156 -0
- greedy_token/context_audit.py +105 -0
- greedy_token/estimator.py +97 -0
- greedy_token/executors.py +98 -0
- greedy_token/paths.py +32 -0
- greedy_token/prompt_compress.py +105 -0
- greedy_token/rag_search.py +128 -0
- greedy_token/router.py +340 -0
- greedy_token/tokens.py +121 -0
- greedy_token/wrappers.py +116 -0
- greedy_token-0.2.1.dist-info/METADATA +151 -0
- greedy_token-0.2.1.dist-info/RECORD +19 -0
- greedy_token-0.2.1.dist-info/WHEEL +5 -0
- greedy_token-0.2.1.dist-info/entry_points.txt +2 -0
- greedy_token-0.2.1.dist-info/licenses/LICENSE +21 -0
- greedy_token-0.2.1.dist-info/top_level.txt +1 -0
greedy_token/__init__.py
ADDED
greedy_token/__main__.py
ADDED
greedy_token/cli.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from greedy_token.context_audit import audit_context, render_audit
|
|
7
|
+
from greedy_token.estimator import estimate_task, format_estimate
|
|
8
|
+
from greedy_token.executors import execute_plan, plan_run
|
|
9
|
+
from greedy_token.paths import find_monorepo_root
|
|
10
|
+
from greedy_token.prompt_compress import compress_prompt, format_dual
|
|
11
|
+
from greedy_token.rag_search import format_hits, search_rag
|
|
12
|
+
from greedy_token.router import format_decision, route_task
|
|
13
|
+
from greedy_token.tokens import TokenEstimate, collect_paths, count_files, format_size_table
|
|
14
|
+
from greedy_token.wrappers import WRAPPERS, ollama_status_line, resolve_wrapper_command
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cmd_route(args: argparse.Namespace) -> int:
|
|
18
|
+
root = find_monorepo_root()
|
|
19
|
+
decision = route_task(args.task, root)
|
|
20
|
+
print(format_decision(decision, args.task, root))
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_estimate(args: argparse.Namespace) -> int:
|
|
25
|
+
root = find_monorepo_root()
|
|
26
|
+
estimate = estimate_task(args.task, root)
|
|
27
|
+
print(format_estimate(estimate, args.task, root))
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
32
|
+
root = find_monorepo_root()
|
|
33
|
+
decision = route_task(args.task, root)
|
|
34
|
+
plan = plan_run(decision, args.task, root)
|
|
35
|
+
print(f"Route: {decision.target} ({decision.route_id})")
|
|
36
|
+
print(f"Complexity: {decision.complexity} Est. tokens: {decision.est_tokens:,}")
|
|
37
|
+
print()
|
|
38
|
+
if args.execute:
|
|
39
|
+
code, out = execute_plan(plan)
|
|
40
|
+
print(out)
|
|
41
|
+
return code
|
|
42
|
+
print(plan.dry_run_output)
|
|
43
|
+
if plan.command:
|
|
44
|
+
if plan.executable:
|
|
45
|
+
print("\n(read-only — add --execute to run)")
|
|
46
|
+
else:
|
|
47
|
+
print("\n(not read-only — dry-run only)")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_audit_context(_: argparse.Namespace) -> int:
|
|
52
|
+
items = audit_context()
|
|
53
|
+
print(render_audit(items))
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_tokens(args: argparse.Namespace) -> int:
|
|
58
|
+
root = find_monorepo_root()
|
|
59
|
+
paths = collect_paths(args.paths, root)
|
|
60
|
+
if not paths:
|
|
61
|
+
print("No files found.", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
estimates = count_files(paths)
|
|
64
|
+
rows = []
|
|
65
|
+
total_chars = 0
|
|
66
|
+
total_tokens = 0
|
|
67
|
+
method = "heuristic/4"
|
|
68
|
+
for p, est in zip(paths, estimates):
|
|
69
|
+
rel = str(p.relative_to(root)) if p.is_relative_to(root) else str(p)
|
|
70
|
+
rows.append((rel, est))
|
|
71
|
+
total_chars += est.chars
|
|
72
|
+
total_tokens += est.tokens
|
|
73
|
+
method = est.method
|
|
74
|
+
rows.sort(key=lambda r: -r[1].tokens)
|
|
75
|
+
total = TokenEstimate(tokens=total_tokens, chars=total_chars, method=method)
|
|
76
|
+
print(format_size_table(rows, total))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_rag(args: argparse.Namespace) -> int:
|
|
81
|
+
root = find_monorepo_root()
|
|
82
|
+
domains = args.domain.split(",") if args.domain else None
|
|
83
|
+
hits = search_rag(args.query, root, domains=domains, limit=args.limit)
|
|
84
|
+
print(format_hits(args.query, hits))
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_compress(args: argparse.Namespace) -> int:
|
|
89
|
+
text = sys.stdin.read()
|
|
90
|
+
if not text.strip():
|
|
91
|
+
print("Read prompt from stdin.", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
short = compress_prompt(text, use_ollama=args.ollama)
|
|
94
|
+
if args.raw:
|
|
95
|
+
print(short)
|
|
96
|
+
else:
|
|
97
|
+
print(format_dual(text, short))
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_scripts(args: argparse.Namespace) -> int:
|
|
102
|
+
root = find_monorepo_root()
|
|
103
|
+
if args.list:
|
|
104
|
+
lines = ["Script wrappers (scripts/ollama | migrate | check-meta-sync):", ""]
|
|
105
|
+
for wrapper in WRAPPERS.values():
|
|
106
|
+
ro = "read-only" if wrapper.read_only else "writes"
|
|
107
|
+
oll = " +ollama" if wrapper.requires_ollama else ""
|
|
108
|
+
lines.append(f" {wrapper.id:<20} [{wrapper.category}] {ro}{oll}")
|
|
109
|
+
lines.append(f" {wrapper.path}")
|
|
110
|
+
if wrapper.note:
|
|
111
|
+
lines.append(f" {wrapper.note}")
|
|
112
|
+
lines.append("")
|
|
113
|
+
lines.append(ollama_status_line())
|
|
114
|
+
print("\n".join(lines))
|
|
115
|
+
return 0
|
|
116
|
+
if args.run:
|
|
117
|
+
try:
|
|
118
|
+
cmd = resolve_wrapper_command(args.run, root, extra_args=args.args or "")
|
|
119
|
+
except (KeyError, FileNotFoundError) as exc:
|
|
120
|
+
print(exc, file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
wrapper = WRAPPERS[args.run]
|
|
123
|
+
if args.execute:
|
|
124
|
+
if not wrapper.read_only:
|
|
125
|
+
print(
|
|
126
|
+
f"Refusing --execute: {args.run} is not read-only.",
|
|
127
|
+
file=sys.stderr,
|
|
128
|
+
)
|
|
129
|
+
return 1
|
|
130
|
+
import subprocess
|
|
131
|
+
|
|
132
|
+
proc = subprocess.run(cmd, shell=True)
|
|
133
|
+
return proc.returncode
|
|
134
|
+
print(cmd)
|
|
135
|
+
if wrapper.read_only:
|
|
136
|
+
print("\n(read-only — add --execute to run)")
|
|
137
|
+
else:
|
|
138
|
+
print("\n(not read-only — dry-run only)")
|
|
139
|
+
return 0
|
|
140
|
+
print("Use scripts --list or scripts --run ID", file=sys.stderr)
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
145
|
+
p = argparse.ArgumentParser(
|
|
146
|
+
prog="greedy-token",
|
|
147
|
+
description="Task orchestrator: tool | Python | Ollama | RAG | Cursor",
|
|
148
|
+
)
|
|
149
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
150
|
+
|
|
151
|
+
r = sub.add_parser("route", help="Recommend executor for a task")
|
|
152
|
+
r.add_argument("task", help="Natural language task description")
|
|
153
|
+
r.set_defaults(func=cmd_route)
|
|
154
|
+
|
|
155
|
+
est = sub.add_parser("estimate", help="Token-aware route estimate with tier scan")
|
|
156
|
+
est.add_argument("task", help="Task description")
|
|
157
|
+
est.set_defaults(func=cmd_estimate)
|
|
158
|
+
|
|
159
|
+
run = sub.add_parser("run", help="Route and show/run command")
|
|
160
|
+
run.add_argument("task", help="Task description")
|
|
161
|
+
run.add_argument(
|
|
162
|
+
"--execute",
|
|
163
|
+
action="store_true",
|
|
164
|
+
help="Execute read-only tool/python commands only",
|
|
165
|
+
)
|
|
166
|
+
run.set_defaults(func=cmd_run)
|
|
167
|
+
|
|
168
|
+
sub.add_parser("audit-context", help="Token audit of rules/skills").set_defaults(
|
|
169
|
+
func=cmd_audit_context
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
t = sub.add_parser("tokens", help="Count tokens in paths")
|
|
173
|
+
t.add_argument("paths", nargs="+", help="Files or directories")
|
|
174
|
+
t.set_defaults(func=cmd_tokens)
|
|
175
|
+
|
|
176
|
+
rag = sub.add_parser("rag", help="Search docs/rag chunks")
|
|
177
|
+
rag.add_argument("query", help="Search query")
|
|
178
|
+
rag.add_argument("--domain", help="Comma-separated domains filter")
|
|
179
|
+
rag.add_argument("--limit", type=int, default=5)
|
|
180
|
+
rag.set_defaults(func=cmd_rag)
|
|
181
|
+
|
|
182
|
+
c = sub.add_parser("compress", help="Short agent prompt from stdin")
|
|
183
|
+
c.add_argument("--ollama", action="store_true", help="Use local Ollama")
|
|
184
|
+
c.add_argument("--raw", action="store_true", help="Print short text only")
|
|
185
|
+
c.set_defaults(func=cmd_compress)
|
|
186
|
+
|
|
187
|
+
scr = sub.add_parser("scripts", help="Wrappers for monorepo scripts")
|
|
188
|
+
scr.add_argument("--list", action="store_true", help="List script wrappers")
|
|
189
|
+
scr.add_argument("--run", metavar="ID", help="Wrapper id (e.g. check-meta-sync)")
|
|
190
|
+
scr.add_argument("args", nargs="?", default="", help="Extra args for script")
|
|
191
|
+
scr.add_argument(
|
|
192
|
+
"--execute",
|
|
193
|
+
action="store_true",
|
|
194
|
+
help="Run read-only wrapper only",
|
|
195
|
+
)
|
|
196
|
+
scr.set_defaults(func=cmd_scripts)
|
|
197
|
+
|
|
198
|
+
return p
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: list[str] | None = None) -> None:
|
|
202
|
+
parser = build_parser()
|
|
203
|
+
args = parser.parse_args(argv)
|
|
204
|
+
code = args.func(args)
|
|
205
|
+
raise SystemExit(code)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Task routing — aligned with docs/migration-prompts.md § Token economy rules
|
|
2
|
+
#
|
|
3
|
+
# Tier order (tool-first): tool → python → ollama → rag → cursor
|
|
4
|
+
# First matching tier wins (best score within tier).
|
|
5
|
+
|
|
6
|
+
routes:
|
|
7
|
+
- id: tool-rg-search
|
|
8
|
+
target: tool
|
|
9
|
+
tool: rg
|
|
10
|
+
read_only: true
|
|
11
|
+
patterns:
|
|
12
|
+
- find
|
|
13
|
+
- search for
|
|
14
|
+
- grep
|
|
15
|
+
- where is
|
|
16
|
+
- locate
|
|
17
|
+
- look for
|
|
18
|
+
note: "0 tokens — ripgrep before RAG/Cursor"
|
|
19
|
+
|
|
20
|
+
- id: tool-jq-manifest
|
|
21
|
+
target: tool
|
|
22
|
+
tool: jq
|
|
23
|
+
read_only: true
|
|
24
|
+
patterns:
|
|
25
|
+
- jq
|
|
26
|
+
- json field
|
|
27
|
+
- parse json
|
|
28
|
+
- phase-manifest json
|
|
29
|
+
json_path: docs/phase-manifest.json
|
|
30
|
+
jq_filter: .
|
|
31
|
+
note: "Structured JSON lookup — jq, 0 LLM"
|
|
32
|
+
|
|
33
|
+
- id: python-rsync
|
|
34
|
+
target: python
|
|
35
|
+
read_only: false
|
|
36
|
+
patterns:
|
|
37
|
+
- rsync
|
|
38
|
+
- migrate
|
|
39
|
+
- phase1-rsync
|
|
40
|
+
- mechanical copy
|
|
41
|
+
- bulk copy
|
|
42
|
+
command: ./scripts/migrate/phase1-rsync.sh
|
|
43
|
+
note: "0 tokens — shell rsync"
|
|
44
|
+
|
|
45
|
+
- id: python-meta-sync
|
|
46
|
+
target: python
|
|
47
|
+
read_only: true
|
|
48
|
+
patterns:
|
|
49
|
+
- check-meta-sync
|
|
50
|
+
- meta sync
|
|
51
|
+
- phase-manifest
|
|
52
|
+
- skills-map sync
|
|
53
|
+
command: ./scripts/check-meta-sync.sh
|
|
54
|
+
note: "Meta validation without LLM"
|
|
55
|
+
|
|
56
|
+
- id: python-gen-env
|
|
57
|
+
target: python
|
|
58
|
+
read_only: false
|
|
59
|
+
patterns:
|
|
60
|
+
- gen-env-configs
|
|
61
|
+
- env config generate
|
|
62
|
+
- properties sync script
|
|
63
|
+
command: python stacks/java-spring/scripts/gen-env-configs.py
|
|
64
|
+
note: "Deterministic config generation"
|
|
65
|
+
|
|
66
|
+
- id: ollama-inventory
|
|
67
|
+
target: ollama
|
|
68
|
+
read_only: false
|
|
69
|
+
patterns:
|
|
70
|
+
- batch inventory
|
|
71
|
+
- classify files
|
|
72
|
+
- file inventory
|
|
73
|
+
- migration inventory
|
|
74
|
+
- apply-inventory
|
|
75
|
+
command: ./scripts/ollama/batch-inventory.sh
|
|
76
|
+
note: "Bulk classify via local LLM"
|
|
77
|
+
|
|
78
|
+
- id: ollama-audit-skill
|
|
79
|
+
target: ollama
|
|
80
|
+
read_only: false
|
|
81
|
+
patterns:
|
|
82
|
+
- audit skill
|
|
83
|
+
- skill audit
|
|
84
|
+
- review skill md
|
|
85
|
+
command: ./scripts/ollama/audit-skill.sh
|
|
86
|
+
note: "Skill quality audit — Ollama, not Cursor"
|
|
87
|
+
|
|
88
|
+
- id: ollama-rag-draft
|
|
89
|
+
target: ollama
|
|
90
|
+
read_only: false
|
|
91
|
+
patterns:
|
|
92
|
+
- draft rag
|
|
93
|
+
- rag chunk draft
|
|
94
|
+
- batch rag
|
|
95
|
+
- ollama chunk
|
|
96
|
+
command: null
|
|
97
|
+
note: "Draft RAG chunks via scripts/ollama/ — never one-by-one in Cursor"
|
|
98
|
+
|
|
99
|
+
- id: rag-e2e-config
|
|
100
|
+
target: rag
|
|
101
|
+
patterns:
|
|
102
|
+
- e2e config
|
|
103
|
+
- -d flag
|
|
104
|
+
- testconfig
|
|
105
|
+
- env profile
|
|
106
|
+
- baseurl
|
|
107
|
+
- base url
|
|
108
|
+
- healthcheck
|
|
109
|
+
- gradle -d
|
|
110
|
+
domains: [e2e]
|
|
111
|
+
note: "Lookup docs/rag/e2e/e2e-config-keys.md, cfg-*"
|
|
112
|
+
|
|
113
|
+
- id: rag-e2e-patterns
|
|
114
|
+
target: rag
|
|
115
|
+
patterns:
|
|
116
|
+
- page object
|
|
117
|
+
- po locator
|
|
118
|
+
- selenide
|
|
119
|
+
- test pyramid
|
|
120
|
+
- test layer
|
|
121
|
+
- allure attach
|
|
122
|
+
- ci workflow
|
|
123
|
+
- allurerc
|
|
124
|
+
domains: [e2e, e2e-header]
|
|
125
|
+
note: "e2e patterns — read chunk, don't re-derive in chat"
|
|
126
|
+
|
|
127
|
+
- id: rag-stacks
|
|
128
|
+
target: rag
|
|
129
|
+
patterns:
|
|
130
|
+
- stack template
|
|
131
|
+
- openapi contract
|
|
132
|
+
- pyramid-map
|
|
133
|
+
- flows/login
|
|
134
|
+
domains: [stacks]
|
|
135
|
+
note: "stacks/_contract + docs/rag/stacks/"
|
|
136
|
+
|
|
137
|
+
- id: cursor-wiring
|
|
138
|
+
target: cursor
|
|
139
|
+
patterns:
|
|
140
|
+
- implement
|
|
141
|
+
- refactor
|
|
142
|
+
- fix bug
|
|
143
|
+
- add component
|
|
144
|
+
- header layout
|
|
145
|
+
- spring backend
|
|
146
|
+
- wiring
|
|
147
|
+
- architecture
|
|
148
|
+
- new feature
|
|
149
|
+
- e2e test class
|
|
150
|
+
note: "One chat = one zone; use skill if listed"
|
|
151
|
+
|
|
152
|
+
cursor_fallback:
|
|
153
|
+
message: |
|
|
154
|
+
Нет точного match — скорее всего Cursor/Claude.
|
|
155
|
+
Проверь: один чат = одна зона (agent-scope.mdc).
|
|
156
|
+
Перед чатом: greedy-token audit-context && greedy-token rag "<topic>".
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from greedy_token.paths import find_monorepo_root
|
|
7
|
+
from greedy_token.tokens import TokenEstimate, count_files, format_size_table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ContextItem:
|
|
12
|
+
path: str
|
|
13
|
+
kind: str
|
|
14
|
+
always_on: bool
|
|
15
|
+
estimate: TokenEstimate
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def audit_context(root: Path | None = None) -> list[ContextItem]:
|
|
19
|
+
root = root or find_monorepo_root()
|
|
20
|
+
items: list[ContextItem] = []
|
|
21
|
+
|
|
22
|
+
globs = [
|
|
23
|
+
(".cursor/rules/*.mdc", "rule", True),
|
|
24
|
+
(".cursor/skills/*/SKILL.md", "skill", False),
|
|
25
|
+
("docs/CONTEXT.md", "doc", False),
|
|
26
|
+
("docs/migration-prompts.md", "doc", False),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
found: list[tuple[Path, str, bool]] = []
|
|
30
|
+
for pattern, kind, always_on in globs:
|
|
31
|
+
for path in sorted(root.glob(pattern)):
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
continue
|
|
34
|
+
found.append((path, kind, always_on))
|
|
35
|
+
|
|
36
|
+
estimates = count_files([f[0] for f in found])
|
|
37
|
+
for (path, kind, always_on), estimate in zip(found, estimates):
|
|
38
|
+
items.append(
|
|
39
|
+
ContextItem(
|
|
40
|
+
path=str(path.relative_to(root)),
|
|
41
|
+
kind=kind,
|
|
42
|
+
always_on=always_on,
|
|
43
|
+
estimate=estimate,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return items
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_audit(items: list[ContextItem]) -> str:
|
|
51
|
+
always = [i for i in items if i.always_on]
|
|
52
|
+
rules_total = sum(i.estimate.tokens for i in always)
|
|
53
|
+
skills_total = sum(i.estimate.tokens for i in items if i.kind == "skill")
|
|
54
|
+
all_total = sum(i.estimate.tokens for i in items)
|
|
55
|
+
|
|
56
|
+
lines = [
|
|
57
|
+
"== Cursor context audit ==",
|
|
58
|
+
"",
|
|
59
|
+
f"Always-on rules (.cursor/rules/*.mdc): {rules_total:,} tokens",
|
|
60
|
+
f"Skills on disk (.cursor/skills/*/SKILL.md): {skills_total:,} tokens",
|
|
61
|
+
f"Sampled docs: {all_total - rules_total - skills_total:,} tokens",
|
|
62
|
+
f"Grand total (sampled set): {all_total:,} tokens",
|
|
63
|
+
"",
|
|
64
|
+
"Always-on rules (charged every chat):",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
rule_rows = [
|
|
68
|
+
(i.path, i.estimate) for i in sorted(always, key=lambda x: -x.estimate.tokens)
|
|
69
|
+
]
|
|
70
|
+
if rule_rows:
|
|
71
|
+
total = TokenEstimate(
|
|
72
|
+
tokens=rules_total,
|
|
73
|
+
chars=sum(i.estimate.chars for i in always),
|
|
74
|
+
method=always[0].estimate.method if always else "n/a",
|
|
75
|
+
)
|
|
76
|
+
lines.append(format_size_table(rule_rows, total))
|
|
77
|
+
else:
|
|
78
|
+
lines.append(" (none)")
|
|
79
|
+
|
|
80
|
+
lines.extend(["", "Top skills by size:"])
|
|
81
|
+
top_skills = sorted(
|
|
82
|
+
[i for i in items if i.kind == "skill"], key=lambda x: -x.estimate.tokens
|
|
83
|
+
)[:10]
|
|
84
|
+
for s in top_skills:
|
|
85
|
+
lines.append(f" {s.estimate.tokens:>6} {s.path}")
|
|
86
|
+
|
|
87
|
+
cache_hint = 1024
|
|
88
|
+
if rules_total >= cache_hint:
|
|
89
|
+
lines.extend(
|
|
90
|
+
[
|
|
91
|
+
"",
|
|
92
|
+
f"Note: always-on rules ({rules_total:,} tok) ≥ {cache_hint} —",
|
|
93
|
+
"stable prefix is cache-friendly for Claude API prompt caching.",
|
|
94
|
+
]
|
|
95
|
+
)
|
|
96
|
+
elif rules_total > 0:
|
|
97
|
+
lines.extend(
|
|
98
|
+
[
|
|
99
|
+
"",
|
|
100
|
+
f"Note: always-on rules ({rules_total:,} tok) < {cache_hint} —",
|
|
101
|
+
"below typical prompt-cache minimum; keep rules slim.",
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from greedy_token.context_audit import audit_context
|
|
7
|
+
from greedy_token.router import RouteDecision, route_task, route_task_all_tiers
|
|
8
|
+
from greedy_token.tokens import count_tokens
|
|
9
|
+
from greedy_token.wrappers import ollama_available, ollama_status_line
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TIER_ORDER = ("tool", "python", "ollama", "rag", "cursor")
|
|
13
|
+
|
|
14
|
+
COMPLEXITY_BY_TARGET = {
|
|
15
|
+
"tool": "low",
|
|
16
|
+
"python": "low",
|
|
17
|
+
"ollama": "medium",
|
|
18
|
+
"rag": "low",
|
|
19
|
+
"cursor": "high",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
BASE_CURSOR_OVERHEAD = 6000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class TaskEstimate:
|
|
27
|
+
decision: RouteDecision
|
|
28
|
+
complexity: str
|
|
29
|
+
est_tokens: int
|
|
30
|
+
rationale: str
|
|
31
|
+
cursor_saved: int
|
|
32
|
+
ollama_note: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _cursor_baseline_tokens(root: Path) -> int:
|
|
36
|
+
items = audit_context(root)
|
|
37
|
+
return sum(i.estimate.tokens for i in items if i.always_on)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def estimate_task(task: str, root: Path) -> TaskEstimate:
|
|
41
|
+
decision = route_task(task, root)
|
|
42
|
+
complexity = decision.complexity or COMPLEXITY_BY_TARGET.get(decision.target, "medium")
|
|
43
|
+
est_tokens = decision.est_tokens
|
|
44
|
+
rationale = decision.rationale
|
|
45
|
+
cursor_saved = 0
|
|
46
|
+
ollama_note: str | None = None
|
|
47
|
+
|
|
48
|
+
if decision.target != "cursor":
|
|
49
|
+
baseline = _cursor_baseline_tokens(root)
|
|
50
|
+
task_tokens = count_tokens(task).tokens
|
|
51
|
+
cursor_equiv = baseline + task_tokens + BASE_CURSOR_OVERHEAD
|
|
52
|
+
cursor_saved = max(0, cursor_equiv - est_tokens)
|
|
53
|
+
|
|
54
|
+
if decision.target == "ollama" and not ollama_available():
|
|
55
|
+
ollama_note = ollama_status_line()
|
|
56
|
+
|
|
57
|
+
return TaskEstimate(
|
|
58
|
+
decision=decision,
|
|
59
|
+
complexity=complexity,
|
|
60
|
+
est_tokens=est_tokens,
|
|
61
|
+
rationale=rationale,
|
|
62
|
+
cursor_saved=cursor_saved,
|
|
63
|
+
ollama_note=ollama_note,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_estimate(estimate: TaskEstimate, task: str, root: Path) -> str:
|
|
68
|
+
d = estimate.decision
|
|
69
|
+
lines = [
|
|
70
|
+
f"Task: {task}",
|
|
71
|
+
f"Route: {d.target.upper()} ({d.route_id}, {d.confidence:.0%})",
|
|
72
|
+
f"Complexity: {estimate.complexity}",
|
|
73
|
+
f"Est. Cursor tokens: {estimate.est_tokens:,}",
|
|
74
|
+
f"Rationale: {estimate.rationale}",
|
|
75
|
+
]
|
|
76
|
+
if d.matched:
|
|
77
|
+
lines.append(f"Matched: {', '.join(d.matched)}")
|
|
78
|
+
if d.command:
|
|
79
|
+
cmd = d.command if d.command.startswith("cd ") else f"cd {root} && {d.command}"
|
|
80
|
+
lines.append(f"Command: {cmd}")
|
|
81
|
+
if estimate.cursor_saved > 0:
|
|
82
|
+
lines.append(f"Cursor tokens saved vs agent chat: ~{estimate.cursor_saved:,}")
|
|
83
|
+
if estimate.ollama_note:
|
|
84
|
+
lines.append(f"Note: {estimate.ollama_note}")
|
|
85
|
+
if d.target == "cursor":
|
|
86
|
+
lines.append("→ Новый Cursor-чат; skill из docs/skills-map.md если есть.")
|
|
87
|
+
lines.extend(["", "Tier scan:"])
|
|
88
|
+
for tier, decision in route_task_all_tiers(task, root):
|
|
89
|
+
if decision.matched:
|
|
90
|
+
tag = " ← selected" if decision.route_id == d.route_id else ""
|
|
91
|
+
lines.append(
|
|
92
|
+
f" {tier:<8} {decision.route_id:<22} "
|
|
93
|
+
f"complexity={decision.complexity} est={decision.est_tokens:,}{tag}"
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
lines.append(f" {tier:<8} —")
|
|
97
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from greedy_token.paths import find_monorepo_root
|
|
8
|
+
from greedy_token.rag_search import format_hits, search_rag
|
|
9
|
+
from greedy_token.router import RouteDecision
|
|
10
|
+
from greedy_token.wrappers import wrapper_for_command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class RunPlan:
|
|
15
|
+
decision: RouteDecision
|
|
16
|
+
command: str | None
|
|
17
|
+
dry_run_output: str
|
|
18
|
+
executable: bool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plan_run(decision: RouteDecision, task: str, root: Path | None = None) -> RunPlan:
|
|
22
|
+
root = root or find_monorepo_root()
|
|
23
|
+
target = decision.target
|
|
24
|
+
|
|
25
|
+
if target == "tool" and decision.command:
|
|
26
|
+
return RunPlan(
|
|
27
|
+
decision=decision,
|
|
28
|
+
command=decision.command,
|
|
29
|
+
dry_run_output=decision.command,
|
|
30
|
+
executable=decision.read_only,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if target == "python" and decision.command:
|
|
34
|
+
cmd = f"cd {root} && {decision.command}"
|
|
35
|
+
wrapper = wrapper_for_command(decision.command)
|
|
36
|
+
read_only = decision.read_only or (wrapper.read_only if wrapper else False)
|
|
37
|
+
return RunPlan(
|
|
38
|
+
decision=decision,
|
|
39
|
+
command=cmd,
|
|
40
|
+
dry_run_output=cmd,
|
|
41
|
+
executable=read_only,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if target == "ollama" and decision.command:
|
|
45
|
+
cmd = f"cd {root} && {decision.command}"
|
|
46
|
+
return RunPlan(
|
|
47
|
+
decision=decision,
|
|
48
|
+
command=cmd,
|
|
49
|
+
dry_run_output=cmd + " # pass args as needed",
|
|
50
|
+
executable=False,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if target == "rag":
|
|
54
|
+
hits = search_rag(task, root, domains=decision.domains or None)
|
|
55
|
+
return RunPlan(
|
|
56
|
+
decision=decision,
|
|
57
|
+
command=None,
|
|
58
|
+
dry_run_output=format_hits(task, hits),
|
|
59
|
+
executable=False,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if target == "cursor":
|
|
63
|
+
return RunPlan(
|
|
64
|
+
decision=decision,
|
|
65
|
+
command=None,
|
|
66
|
+
dry_run_output=(
|
|
67
|
+
"Open new Cursor chat.\n"
|
|
68
|
+
f"Task: {task}\n"
|
|
69
|
+
"Before paste: greedy-token audit-context && greedy-token rag \"<topic>\""
|
|
70
|
+
),
|
|
71
|
+
executable=False,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return RunPlan(
|
|
75
|
+
decision=decision,
|
|
76
|
+
command=None,
|
|
77
|
+
dry_run_output="No executor.",
|
|
78
|
+
executable=False,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def execute_plan(plan: RunPlan) -> tuple[int, str]:
|
|
83
|
+
if not plan.command:
|
|
84
|
+
return 0, plan.dry_run_output
|
|
85
|
+
if not plan.executable:
|
|
86
|
+
return 1, (
|
|
87
|
+
"Refusing --execute: route is not read-only.\n"
|
|
88
|
+
f"Dry-run:\n{plan.dry_run_output}\n\n"
|
|
89
|
+
"Run the script manually if side effects are intended."
|
|
90
|
+
)
|
|
91
|
+
proc = subprocess.run(
|
|
92
|
+
plan.command,
|
|
93
|
+
shell=True,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
)
|
|
97
|
+
out = (proc.stdout or "") + (proc.stderr or "")
|
|
98
|
+
return proc.returncode, out or plan.dry_run_output
|
greedy_token/paths.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def find_monorepo_root(start: Path | None = None) -> Path:
|
|
8
|
+
env = os.environ.get("GREEDY_TOKEN_ROOT")
|
|
9
|
+
if env:
|
|
10
|
+
root = Path(env).expanduser().resolve()
|
|
11
|
+
if root.is_dir():
|
|
12
|
+
return root
|
|
13
|
+
raise SystemExit(f"GREEDY_TOKEN_ROOT is not a directory: {root}")
|
|
14
|
+
|
|
15
|
+
here = (start or Path(__file__)).resolve()
|
|
16
|
+
for parent in [here, *here.parents]:
|
|
17
|
+
if (parent / "docs" / "phase-manifest.json").is_file() and (
|
|
18
|
+
parent / "scripts" / "check-meta-sync.sh"
|
|
19
|
+
).is_file():
|
|
20
|
+
return parent
|
|
21
|
+
|
|
22
|
+
raise SystemExit(
|
|
23
|
+
"Cannot find monorepo root. Set GREEDY_TOKEN_ROOT=/path/to/zero-design-system"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_routes_config() -> dict:
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
config_path = Path(__file__).parent / "config" / "routes.yaml"
|
|
31
|
+
with config_path.open(encoding="utf-8") as f:
|
|
32
|
+
return yaml.safe_load(f)
|