devrel-origin 0.2.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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Load .devrel/config.toml into a typed ProjectConfig.
|
|
2
|
+
|
|
3
|
+
The schema is intentionally narrow: project identity (required), model
|
|
4
|
+
selection (optional with sensible defaults), and budget guardrails
|
|
5
|
+
(optional). Future phases extend this with additional sections; the loader
|
|
6
|
+
is permissive about unknown top-level keys.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(Exception):
|
|
17
|
+
"""Raised when config.toml is malformed or missing required fields."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ProjectIdentity:
|
|
22
|
+
name: str
|
|
23
|
+
url: str = ""
|
|
24
|
+
github_repo: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ModelConfig:
|
|
29
|
+
default: str = "claude-sonnet-4-6"
|
|
30
|
+
cheap: str = "claude-haiku-4-5-20251001"
|
|
31
|
+
opus_opt_in: bool = True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class BudgetConfig:
|
|
36
|
+
monthly_usd: float = 100.0
|
|
37
|
+
warn_at_pct: int = 80
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ProjectConfig:
|
|
42
|
+
project: ProjectIdentity
|
|
43
|
+
model: ModelConfig = field(default_factory=ModelConfig)
|
|
44
|
+
budget: BudgetConfig = field(default_factory=BudgetConfig)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def load(cls, config_file: Path) -> "ProjectConfig":
|
|
48
|
+
if not config_file.is_file():
|
|
49
|
+
raise ConfigError(f"config.toml not found at {config_file}")
|
|
50
|
+
with config_file.open("rb") as f:
|
|
51
|
+
raw = tomllib.load(f)
|
|
52
|
+
if "project" not in raw:
|
|
53
|
+
raise ConfigError("config.toml missing required [project] section")
|
|
54
|
+
proj = raw["project"]
|
|
55
|
+
if "name" not in proj or not proj["name"]:
|
|
56
|
+
raise ConfigError("config.toml missing required project.name")
|
|
57
|
+
identity = ProjectIdentity(
|
|
58
|
+
name=str(proj["name"]),
|
|
59
|
+
url=str(proj.get("url", "")),
|
|
60
|
+
github_repo=proj.get("github_repo"),
|
|
61
|
+
)
|
|
62
|
+
model_raw = raw.get("model") or {}
|
|
63
|
+
defaults = ModelConfig()
|
|
64
|
+
model = ModelConfig(
|
|
65
|
+
default=str(model_raw.get("default", defaults.default)),
|
|
66
|
+
cheap=str(model_raw.get("cheap", defaults.cheap)),
|
|
67
|
+
opus_opt_in=bool(model_raw.get("opus_opt_in", defaults.opus_opt_in)),
|
|
68
|
+
)
|
|
69
|
+
budget_raw = raw.get("budget") or {}
|
|
70
|
+
bdefaults = BudgetConfig()
|
|
71
|
+
budget = BudgetConfig(
|
|
72
|
+
monthly_usd=float(budget_raw.get("monthly_usd", bdefaults.monthly_usd)),
|
|
73
|
+
warn_at_pct=int(budget_raw.get("warn_at_pct", bdefaults.warn_at_pct)),
|
|
74
|
+
)
|
|
75
|
+
return cls(project=identity, model=model, budget=budget)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Build a cost-sink callable that inserts rows into the project state DB.
|
|
2
|
+
|
|
3
|
+
Used by Atlas to wire LLMClient cost events into `.devrel/state.db`'s
|
|
4
|
+
`costs` table. The pricing table lives in core/llm.py — we read it
|
|
5
|
+
indirectly via the model names we receive.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from devrel_origin.core.llm import MODEL_COSTS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _compute_cost_usd(model: str, usage: dict[str, Any]) -> float:
|
|
18
|
+
pricing = MODEL_COSTS.get(model)
|
|
19
|
+
if pricing is None:
|
|
20
|
+
return 0.0
|
|
21
|
+
input_per_1m = pricing["input"]
|
|
22
|
+
output_per_1m = pricing["output"]
|
|
23
|
+
input_tokens = usage.get("input_tokens", 0)
|
|
24
|
+
output_tokens = usage.get("output_tokens", 0)
|
|
25
|
+
cache_read = usage.get("cache_read_input_tokens", 0)
|
|
26
|
+
cache_write = usage.get("cache_creation_input_tokens", 0)
|
|
27
|
+
# Cache pricing: read at 0.1×, write at 1.25× of input rate (Anthropic standard)
|
|
28
|
+
cost = (
|
|
29
|
+
(input_tokens / 1_000_000) * input_per_1m
|
|
30
|
+
+ (output_tokens / 1_000_000) * output_per_1m
|
|
31
|
+
+ (cache_read / 1_000_000) * input_per_1m * 0.1
|
|
32
|
+
+ (cache_write / 1_000_000) * input_per_1m * 1.25
|
|
33
|
+
)
|
|
34
|
+
return round(cost, 6)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def make_sqlite_sink(db_path: Path):
|
|
38
|
+
"""Return an async ``(agent, model, usage) -> None`` callback that inserts
|
|
39
|
+
a row into the `costs` table at `db_path`."""
|
|
40
|
+
|
|
41
|
+
async def _sink(agent: str, model: str, usage: dict[str, Any]) -> None:
|
|
42
|
+
cost_usd = _compute_cost_usd(model, usage)
|
|
43
|
+
# SQLite is sync; we accept the brief blocking write inline.
|
|
44
|
+
with sqlite3.connect(db_path) as conn:
|
|
45
|
+
conn.execute(
|
|
46
|
+
"INSERT INTO costs (agent, model, input_tokens, output_tokens, "
|
|
47
|
+
"cache_read_tokens, cache_write_tokens, cost_usd) "
|
|
48
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
49
|
+
(
|
|
50
|
+
agent,
|
|
51
|
+
model,
|
|
52
|
+
usage.get("input_tokens", 0),
|
|
53
|
+
usage.get("output_tokens", 0),
|
|
54
|
+
usage.get("cache_read_input_tokens", 0),
|
|
55
|
+
usage.get("cache_creation_input_tokens", 0),
|
|
56
|
+
cost_usd,
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
conn.commit()
|
|
60
|
+
|
|
61
|
+
return _sink
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Idempotent .devrel/ scaffolder.
|
|
2
|
+
|
|
3
|
+
`init_project(root, opts)` writes the .devrel/ directory tree, copies the
|
|
4
|
+
template files, substitutes config placeholders, and initializes the state
|
|
5
|
+
DB. Re-running on an existing project preserves user edits to committed
|
|
6
|
+
files (config.toml, voice.md, style.md, slop-blocklist.md, .gitignore) —
|
|
7
|
+
those are listed in `result.skipped`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from devrel_origin.project.paths import ProjectPaths
|
|
17
|
+
from devrel_origin.project.state import init_db
|
|
18
|
+
|
|
19
|
+
_TEMPLATE_PKG = "devrel_origin.project.templates"
|
|
20
|
+
|
|
21
|
+
# Files that are committed and must NEVER be overwritten on re-init.
|
|
22
|
+
_COMMITTED_FILES = ("config.toml", "voice.md", "style.md", "slop-blocklist.md", ".gitignore")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class InitOptions:
|
|
27
|
+
name: str
|
|
28
|
+
url: str = ""
|
|
29
|
+
github_repo: str | None = None
|
|
30
|
+
dry_run: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class InitResult:
|
|
35
|
+
created: list[str] = field(default_factory=list)
|
|
36
|
+
skipped: list[str] = field(default_factory=list)
|
|
37
|
+
would_create: list[str] = field(default_factory=list)
|
|
38
|
+
dry_run: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _read_template(name: str) -> str:
|
|
42
|
+
return (files(_TEMPLATE_PKG) / name).read_text(encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _render_config_toml(opts: InitOptions) -> str:
|
|
46
|
+
body = _read_template("config.toml")
|
|
47
|
+
body = body.replace("PROJECT_NAME", opts.name)
|
|
48
|
+
body = body.replace("PROJECT_URL", opts.url)
|
|
49
|
+
if opts.github_repo:
|
|
50
|
+
body = body.replace('github_repo = "OWNER/REPO"', f'github_repo = "{opts.github_repo}"')
|
|
51
|
+
else:
|
|
52
|
+
body = body.replace(
|
|
53
|
+
'github_repo = "OWNER/REPO"',
|
|
54
|
+
"# github_repo = # set if this product has a public repo",
|
|
55
|
+
)
|
|
56
|
+
return body
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def init_project(root: Path, opts: InitOptions) -> InitResult:
|
|
60
|
+
"""Scaffold .devrel/ under `root`. Idempotent: preserves committed files
|
|
61
|
+
on re-run."""
|
|
62
|
+
paths = ProjectPaths.from_root(root)
|
|
63
|
+
result = InitResult(dry_run=opts.dry_run)
|
|
64
|
+
|
|
65
|
+
# The directory and subdirectories.
|
|
66
|
+
dirs = [paths.devrel_dir, paths.kb_dir, paths.deliverables_dir, paths.context_dir]
|
|
67
|
+
for d in dirs:
|
|
68
|
+
if d.is_dir():
|
|
69
|
+
result.skipped.append(d.name + "/")
|
|
70
|
+
else:
|
|
71
|
+
if opts.dry_run:
|
|
72
|
+
result.would_create.append(d.name + "/")
|
|
73
|
+
else:
|
|
74
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
result.created.append(d.name + "/")
|
|
76
|
+
|
|
77
|
+
# File payloads keyed by destination path.
|
|
78
|
+
payloads: dict[Path, str] = {
|
|
79
|
+
paths.config_file: _render_config_toml(opts),
|
|
80
|
+
paths.voice_file: _read_template("voice.md"),
|
|
81
|
+
paths.style_file: _read_template("style.md"),
|
|
82
|
+
paths.slop_file: _read_template("slop-blocklist.md"),
|
|
83
|
+
paths.gitignore: _read_template("devrel.gitignore"),
|
|
84
|
+
}
|
|
85
|
+
for dest, body in payloads.items():
|
|
86
|
+
if dest.is_file():
|
|
87
|
+
result.skipped.append(dest.name)
|
|
88
|
+
continue
|
|
89
|
+
if opts.dry_run:
|
|
90
|
+
result.would_create.append(dest.name)
|
|
91
|
+
else:
|
|
92
|
+
dest.write_text(body, encoding="utf-8")
|
|
93
|
+
result.created.append(dest.name)
|
|
94
|
+
|
|
95
|
+
# State DB: idempotent (init_db preserves rows).
|
|
96
|
+
if opts.dry_run:
|
|
97
|
+
if not paths.state_db.is_file():
|
|
98
|
+
result.would_create.append("state.db")
|
|
99
|
+
else:
|
|
100
|
+
already = paths.state_db.is_file()
|
|
101
|
+
init_db(paths.state_db)
|
|
102
|
+
(result.skipped if already else result.created).append("state.db")
|
|
103
|
+
|
|
104
|
+
return result
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Project path discovery and structure.
|
|
2
|
+
|
|
3
|
+
`find_devrel_root` walks up from a starting directory looking for the nearest
|
|
4
|
+
ancestor containing a `.devrel/config.toml`. `ProjectPaths` is a frozen
|
|
5
|
+
dataclass holding every derived path under `.devrel/`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
DEVREL_DIR_NAME = ".devrel"
|
|
14
|
+
CONFIG_FILE_NAME = "config.toml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectNotFoundError(Exception):
|
|
18
|
+
"""Raised when no .devrel/config.toml is found in cwd or any ancestor."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ProjectPaths:
|
|
23
|
+
"""All derived paths for a devrel-origin project."""
|
|
24
|
+
|
|
25
|
+
root: Path
|
|
26
|
+
devrel_dir: Path
|
|
27
|
+
config_file: Path
|
|
28
|
+
voice_file: Path
|
|
29
|
+
style_file: Path
|
|
30
|
+
slop_file: Path
|
|
31
|
+
kb_dir: Path
|
|
32
|
+
deliverables_dir: Path
|
|
33
|
+
context_dir: Path
|
|
34
|
+
state_db: Path
|
|
35
|
+
env_file: Path
|
|
36
|
+
gitignore: Path
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_root(cls, root: Path) -> "ProjectPaths":
|
|
40
|
+
d = root / DEVREL_DIR_NAME
|
|
41
|
+
return cls(
|
|
42
|
+
root=root,
|
|
43
|
+
devrel_dir=d,
|
|
44
|
+
config_file=d / CONFIG_FILE_NAME,
|
|
45
|
+
voice_file=d / "voice.md",
|
|
46
|
+
style_file=d / "style.md",
|
|
47
|
+
slop_file=d / "slop-blocklist.md",
|
|
48
|
+
kb_dir=d / "kb",
|
|
49
|
+
deliverables_dir=d / "deliverables",
|
|
50
|
+
context_dir=d / "context",
|
|
51
|
+
state_db=d / "state.db",
|
|
52
|
+
env_file=d / ".env",
|
|
53
|
+
gitignore=d / ".gitignore",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_devrel_root(start: Path | None = None) -> Path:
|
|
58
|
+
"""Walk up from `start` (default: cwd) until a `.devrel/config.toml` is
|
|
59
|
+
found. Returns the project root (parent of `.devrel/`), resolved to an
|
|
60
|
+
absolute path.
|
|
61
|
+
|
|
62
|
+
Raises ProjectNotFoundError if no `.devrel/config.toml` is found before
|
|
63
|
+
the filesystem root.
|
|
64
|
+
"""
|
|
65
|
+
cur = (start if start is not None else Path.cwd()).resolve()
|
|
66
|
+
while True:
|
|
67
|
+
candidate = cur / DEVREL_DIR_NAME / CONFIG_FILE_NAME
|
|
68
|
+
if candidate.is_file():
|
|
69
|
+
return cur
|
|
70
|
+
if cur.parent == cur:
|
|
71
|
+
raise ProjectNotFoundError(
|
|
72
|
+
"No .devrel/config.toml found in cwd or any ancestor. "
|
|
73
|
+
"Run `devrel init` from the project root."
|
|
74
|
+
)
|
|
75
|
+
cur = cur.parent
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Project state DB: SQLite at .devrel/state.db.
|
|
2
|
+
|
|
3
|
+
Stores: jobs (kind, status, started/finished timestamps), costs (per-call
|
|
4
|
+
token + USD ledger consumed by BudgetGate), checkpoints (per-agent context
|
|
5
|
+
snapshots, one per (agent, week_of) pair).
|
|
6
|
+
|
|
7
|
+
In Phase 2 the DB is initialized empty by `devrel init`. Agents start
|
|
8
|
+
writing to it in Phase 3 (quality pipeline cost recording) and beyond.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Iterator
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 5
|
|
19
|
+
|
|
20
|
+
SCHEMA = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
22
|
+
version INTEGER PRIMARY KEY,
|
|
23
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
kind TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'completed', 'failed')),
|
|
30
|
+
started_at TEXT,
|
|
31
|
+
finished_at TEXT,
|
|
32
|
+
error TEXT
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS costs (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
job_id TEXT,
|
|
38
|
+
agent TEXT NOT NULL,
|
|
39
|
+
model TEXT NOT NULL,
|
|
40
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
44
|
+
cost_usd REAL NOT NULL,
|
|
45
|
+
recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
46
|
+
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE SET NULL
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS checkpoints (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
agent TEXT NOT NULL,
|
|
52
|
+
week_of TEXT NOT NULL,
|
|
53
|
+
payload_json TEXT NOT NULL,
|
|
54
|
+
saved_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
55
|
+
UNIQUE (agent, week_of)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS analytics_reports (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
period_start TEXT NOT NULL,
|
|
61
|
+
period_end TEXT NOT NULL,
|
|
62
|
+
report_json TEXT NOT NULL,
|
|
63
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_analytics_reports_period
|
|
67
|
+
ON analytics_reports(period_end);
|
|
68
|
+
|
|
69
|
+
CREATE TABLE IF NOT EXISTS metric_history (
|
|
70
|
+
content_id TEXT NOT NULL,
|
|
71
|
+
period_end TEXT NOT NULL,
|
|
72
|
+
primary_metric REAL NOT NULL,
|
|
73
|
+
metric_name TEXT NOT NULL,
|
|
74
|
+
content_type TEXT NOT NULL,
|
|
75
|
+
PRIMARY KEY (content_id, period_end)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_metric_history_content_period
|
|
79
|
+
ON metric_history(content_id, period_end DESC);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS analytics_recommendations (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
report_id INTEGER NOT NULL,
|
|
84
|
+
period_end TEXT NOT NULL,
|
|
85
|
+
action TEXT NOT NULL,
|
|
86
|
+
target TEXT NOT NULL,
|
|
87
|
+
target_type TEXT NOT NULL,
|
|
88
|
+
rationale TEXT NOT NULL,
|
|
89
|
+
confidence REAL NOT NULL,
|
|
90
|
+
source_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
91
|
+
evidence_json TEXT NOT NULL DEFAULT '[]',
|
|
92
|
+
first_seen_period TEXT NOT NULL,
|
|
93
|
+
applied_at TEXT,
|
|
94
|
+
FOREIGN KEY (report_id) REFERENCES analytics_reports(id) ON DELETE CASCADE
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_analytics_recommendations_period
|
|
98
|
+
ON analytics_recommendations(period_end);
|
|
99
|
+
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_analytics_recommendations_action_open
|
|
101
|
+
ON analytics_recommendations(action, applied_at);
|
|
102
|
+
|
|
103
|
+
CREATE TABLE IF NOT EXISTS seo_keyword_metrics (
|
|
104
|
+
keyword TEXT NOT NULL,
|
|
105
|
+
page_url TEXT NOT NULL,
|
|
106
|
+
period_end TEXT NOT NULL,
|
|
107
|
+
position REAL,
|
|
108
|
+
ctr REAL,
|
|
109
|
+
impressions INTEGER,
|
|
110
|
+
clicks INTEGER,
|
|
111
|
+
PRIMARY KEY (keyword, page_url, period_end)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_seo_keyword_metrics_period
|
|
115
|
+
ON seo_keyword_metrics(period_end DESC);
|
|
116
|
+
|
|
117
|
+
CREATE TABLE IF NOT EXISTS seo_page_profiles (
|
|
118
|
+
page_url TEXT NOT NULL,
|
|
119
|
+
period_end TEXT NOT NULL,
|
|
120
|
+
title_len INTEGER,
|
|
121
|
+
meta_len INTEGER,
|
|
122
|
+
h1_count INTEGER,
|
|
123
|
+
word_count INTEGER,
|
|
124
|
+
has_schema INTEGER,
|
|
125
|
+
schema_types_json TEXT,
|
|
126
|
+
internal_links INTEGER,
|
|
127
|
+
inp_ms INTEGER,
|
|
128
|
+
lcp_ms INTEGER,
|
|
129
|
+
redirect_chain_len INTEGER,
|
|
130
|
+
crawled_at TEXT NOT NULL,
|
|
131
|
+
PRIMARY KEY (page_url, period_end)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
CREATE TABLE IF NOT EXISTS geo_visibility (
|
|
135
|
+
prompt_id TEXT NOT NULL,
|
|
136
|
+
engine TEXT NOT NULL,
|
|
137
|
+
period_end TEXT NOT NULL,
|
|
138
|
+
is_mentioned INTEGER,
|
|
139
|
+
mention_type TEXT,
|
|
140
|
+
position_score INTEGER,
|
|
141
|
+
citation_share REAL,
|
|
142
|
+
quality_score INTEGER,
|
|
143
|
+
response_path TEXT,
|
|
144
|
+
PRIMARY KEY (prompt_id, engine, period_end)
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_geo_visibility_engine_period
|
|
148
|
+
ON geo_visibility(engine, period_end DESC);
|
|
149
|
+
|
|
150
|
+
CREATE TABLE IF NOT EXISTS cro_funnel_metrics (
|
|
151
|
+
funnel_id TEXT NOT NULL,
|
|
152
|
+
step_index INTEGER NOT NULL,
|
|
153
|
+
period_end TEXT NOT NULL,
|
|
154
|
+
conversion_rate REAL,
|
|
155
|
+
sample_size INTEGER,
|
|
156
|
+
segment_breakdown_json TEXT,
|
|
157
|
+
PRIMARY KEY (funnel_id, step_index, period_end)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_cro_funnel_period
|
|
161
|
+
ON cro_funnel_metrics(funnel_id, period_end DESC);
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _migrate_to_v5(conn: sqlite3.Connection) -> None:
|
|
166
|
+
"""Add pillar + target_kind columns to analytics_recommendations if absent.
|
|
167
|
+
|
|
168
|
+
SQLite's ALTER TABLE ADD COLUMN is non-idempotent: running twice raises
|
|
169
|
+
OperationalError. We probe PRAGMA table_info first.
|
|
170
|
+
"""
|
|
171
|
+
cur = conn.execute("PRAGMA table_info(analytics_recommendations)")
|
|
172
|
+
cols = {row[1] for row in cur.fetchall()}
|
|
173
|
+
if "pillar" not in cols:
|
|
174
|
+
conn.execute(
|
|
175
|
+
"ALTER TABLE analytics_recommendations ADD COLUMN pillar TEXT NOT NULL DEFAULT 'argus'"
|
|
176
|
+
)
|
|
177
|
+
if "target_kind" not in cols:
|
|
178
|
+
conn.execute(
|
|
179
|
+
"ALTER TABLE analytics_recommendations "
|
|
180
|
+
"ADD COLUMN target_kind TEXT NOT NULL DEFAULT 'content_id'"
|
|
181
|
+
)
|
|
182
|
+
# Backfill any rows that pre-date these columns. DEFAULT covers fresh
|
|
183
|
+
# inserts but old rows from a partial v4 db benefit from explicit values.
|
|
184
|
+
conn.execute(
|
|
185
|
+
"UPDATE analytics_recommendations "
|
|
186
|
+
"SET pillar = COALESCE(NULLIF(pillar, ''), 'argus'), "
|
|
187
|
+
" target_kind = COALESCE(NULLIF(target_kind, ''), 'content_id') "
|
|
188
|
+
"WHERE pillar IS NULL OR pillar = '' "
|
|
189
|
+
" OR target_kind IS NULL OR target_kind = ''"
|
|
190
|
+
)
|
|
191
|
+
# Indexes that reference the newly-added columns. Created here (not in
|
|
192
|
+
# SCHEMA) because SCHEMA's executescript runs before the ALTER TABLEs above.
|
|
193
|
+
conn.execute(
|
|
194
|
+
"CREATE INDEX IF NOT EXISTS idx_recs_pillar_period "
|
|
195
|
+
"ON analytics_recommendations(pillar, first_seen_period DESC)"
|
|
196
|
+
)
|
|
197
|
+
conn.execute(
|
|
198
|
+
"CREATE INDEX IF NOT EXISTS idx_recs_target "
|
|
199
|
+
"ON analytics_recommendations(target_kind, target)"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def init_db(db_path: Path) -> None:
|
|
204
|
+
"""Create the DB file and apply the schema. Idempotent: preserves
|
|
205
|
+
existing data and bumps schema_meta to the current SCHEMA_VERSION."""
|
|
206
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
with sqlite3.connect(db_path) as conn:
|
|
208
|
+
conn.executescript(SCHEMA)
|
|
209
|
+
_migrate_to_v5(conn)
|
|
210
|
+
conn.execute(
|
|
211
|
+
"INSERT OR REPLACE INTO schema_meta (version, applied_at) VALUES (?, datetime('now'))",
|
|
212
|
+
(SCHEMA_VERSION,),
|
|
213
|
+
)
|
|
214
|
+
conn.commit()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def get_schema_version(db_path: Path) -> int | None:
|
|
218
|
+
"""Return the current schema version, or None if the DB is missing /
|
|
219
|
+
has no schema_meta table."""
|
|
220
|
+
if not db_path.is_file():
|
|
221
|
+
return None
|
|
222
|
+
with sqlite3.connect(db_path) as conn:
|
|
223
|
+
try:
|
|
224
|
+
cur = conn.execute("SELECT MAX(version) FROM schema_meta")
|
|
225
|
+
except sqlite3.OperationalError:
|
|
226
|
+
return None
|
|
227
|
+
row = cur.fetchone()
|
|
228
|
+
return row[0] if row else None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@contextmanager
|
|
232
|
+
def open_db(db_path: Path) -> Iterator[sqlite3.Connection]:
|
|
233
|
+
"""Context manager yielding a connection with row_factory set and
|
|
234
|
+
foreign-key enforcement enabled."""
|
|
235
|
+
conn = sqlite3.connect(db_path)
|
|
236
|
+
conn.row_factory = sqlite3.Row
|
|
237
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
238
|
+
try:
|
|
239
|
+
yield conn
|
|
240
|
+
finally:
|
|
241
|
+
conn.close()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# devrel-origin project config. Commit this file — it encodes editorial
|
|
2
|
+
# contract along with voice.md / style.md / slop-blocklist.md.
|
|
3
|
+
|
|
4
|
+
# Project identity. Replace placeholders with real values; the github_repo
|
|
5
|
+
# is optional (set to a comment-out or null if there isn't one).
|
|
6
|
+
[project]
|
|
7
|
+
name = "PROJECT_NAME"
|
|
8
|
+
url = "PROJECT_URL"
|
|
9
|
+
github_repo = "OWNER/REPO"
|
|
10
|
+
|
|
11
|
+
# Model selection. Sonnet for primary work; Haiku for cheap quality stages
|
|
12
|
+
# (slop lint, persona scoring, readability scoring); Opus opt-in via
|
|
13
|
+
# `--model opus` on individual commands.
|
|
14
|
+
[model]
|
|
15
|
+
default = "claude-sonnet-4-6"
|
|
16
|
+
cheap = "claude-haiku-4-5-20251001"
|
|
17
|
+
opus_opt_in = true
|
|
18
|
+
|
|
19
|
+
# Budget guardrails. monthly_usd is the cap; warn_at_pct is the threshold
|
|
20
|
+
# at which `devrel doctor` and `devrel run` print a warning. Set
|
|
21
|
+
# monthly_usd to 0 to disable the cap entirely.
|
|
22
|
+
[budget]
|
|
23
|
+
monthly_usd = 100.0
|
|
24
|
+
warn_at_pct = 80
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Auto-managed by `devrel init`. Edit only if you know what you're doing.
|
|
2
|
+
# Generated outputs and runtime state are gitignored; the editorial
|
|
3
|
+
# contract files (config.toml, voice.md, style.md, slop-blocklist.md) are
|
|
4
|
+
# intended to be committed.
|
|
5
|
+
|
|
6
|
+
kb/
|
|
7
|
+
deliverables/
|
|
8
|
+
context/
|
|
9
|
+
state.db
|
|
10
|
+
.env
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Anti-slop blocklist
|
|
2
|
+
|
|
3
|
+
Words, phrases, and patterns that mark text as AI-written. The quality pipeline rewrites any content that contains a hit; on second failure it aborts loud with a report listing offenders.
|
|
4
|
+
|
|
5
|
+
One entry per line. Lines starting with `#` are comments and ignored. Matching is case-insensitive against word boundaries.
|
|
6
|
+
|
|
7
|
+
## Hedge words and filler
|
|
8
|
+
perhaps
|
|
9
|
+
furthermore
|
|
10
|
+
moreover
|
|
11
|
+
in conclusion
|
|
12
|
+
in today's
|
|
13
|
+
in this fast-paced world
|
|
14
|
+
|
|
15
|
+
## AI tells
|
|
16
|
+
delve
|
|
17
|
+
delves
|
|
18
|
+
tapestry
|
|
19
|
+
seamless
|
|
20
|
+
seamlessly
|
|
21
|
+
unleash
|
|
22
|
+
unleashing
|
|
23
|
+
revolutionize
|
|
24
|
+
revolutionary
|
|
25
|
+
empower
|
|
26
|
+
empowering
|
|
27
|
+
groundbreaking
|
|
28
|
+
|
|
29
|
+
## Generic CTAs
|
|
30
|
+
learn more
|
|
31
|
+
discover more
|
|
32
|
+
get started today
|
|
33
|
+
contact us today
|
|
34
|
+
|
|
35
|
+
## Listicle filler
|
|
36
|
+
in this article we will
|
|
37
|
+
this article will explore
|
|
38
|
+
in this post
|
|
39
|
+
|
|
40
|
+
## Empty intensifiers
|
|
41
|
+
truly
|
|
42
|
+
incredibly
|
|
43
|
+
extremely
|
|
44
|
+
very
|
|
45
|
+
really
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# House style
|
|
2
|
+
|
|
3
|
+
Structural and per-content-type rules. Short rules; expand only where the rule isn't obvious from the rule itself.
|
|
4
|
+
|
|
5
|
+
## Structural rules
|
|
6
|
+
|
|
7
|
+
- Sentence-case headings (not Title Case).
|
|
8
|
+
- One H1 per document (the title).
|
|
9
|
+
- Code blocks always have language tags: ```python, ```bash, etc.
|
|
10
|
+
- No trailing whitespace.
|
|
11
|
+
- Reference-style links only when the same URL repeats.
|
|
12
|
+
- No emojis in headings; sparingly in body.
|
|
13
|
+
|
|
14
|
+
## Per-content-type targets
|
|
15
|
+
|
|
16
|
+
| Content type | Flesch-Kincaid | Mean sentence length | Jargon density |
|
|
17
|
+
|---|---|---|---|
|
|
18
|
+
| Tutorial | 50-65 | 12-18 words | medium |
|
|
19
|
+
| Blog post | 55-70 | 12-20 words | low-medium |
|
|
20
|
+
| Landing page | 60-75 | 10-15 words | low |
|
|
21
|
+
| Cold email | 65-80 | 10-14 words | low |
|
|
22
|
+
| Battle card | 45-60 | 12-18 words | medium-high |
|
|
23
|
+
|
|
24
|
+
Targets are guidance, not pass/fail gates. The readability check in the quality pipeline flags drift greater than ±10 points from the Flesch-Kincaid target.
|