blitz-cli 0.15.0__tar.gz → 0.16.1__tar.gz
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.
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.gitignore +7 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/PKG-INFO +1 -1
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/__init__.py +1 -1
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_activity.py +30 -0
- blitz_cli-0.16.1/blitz_cli/_corpus.py +542 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_gitignore.py +19 -6
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/cli.py +23 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/check-prerequisites.sh +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/common.sh +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/create-new-feature.sh +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/setup-plan.sh +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/scripts/bash/setup-tasks.sh +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/locker_room_compose.yml.tmpl +2 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/pyproject.toml +1 -1
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/contracts/ensure-rules.md +8 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/contracts/gitignore-block.md +11 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/checklists/requirements.md +38 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/contracts/gitignore-block.md +94 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/data-model.md +68 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/plan.md +144 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/quickstart.md +160 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/research.md +68 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/spec.md +224 -0
- blitz_cli-0.16.1/specs/006-init-ignore-generated/tasks.md +277 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/checklists/requirements.md +38 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/contracts/activity-db-v2.md +93 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/data-model.md +81 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/plan.md +90 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/quickstart.md +109 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/research.md +182 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/spec.md +151 -0
- blitz_cli-0.16.1/specs/007-usage-data-layer/tasks.md +161 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_activity.py +76 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_cli.py +53 -0
- blitz_cli-0.16.1/tests/test_corpus.py +847 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_gitignore.py +58 -7
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_scaffold.py +15 -0
- blitz_cli-0.15.0/.blitz/templates/plan-template.md +0 -113
- blitz_cli-0.15.0/.blitz/templates/tasks-template.md +0 -252
- blitz_cli-0.15.0/blitz_cli/_corpus.py +0 -260
- blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/check-prerequisites.sh +0 -195
- blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/common.sh +0 -692
- blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/create-new-feature.sh +0 -301
- blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/setup-plan.sh +0 -83
- blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/scripts/bash/setup-tasks.sh +0 -91
- blitz_cli-0.15.0/blitz_cli/playbooks/scaffold/.blitz/templates/spec-template.md +0 -131
- blitz_cli-0.15.0/tests/test_corpus.py +0 -350
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.blitz/memory/playbook.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.github/workflows/homebrew.yml +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.github/workflows/publish.yml +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/.mcp.json +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/LICENSE +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/README.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_code_graph.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_collect.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_collect_http.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_collect_server.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_dataset.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_execute.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_git.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_make.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_ontology.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_playbooks.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_redact.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_scaffold.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_tool_schemas.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/_train.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/__init__.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/__init__.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/analyze.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/clarify.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/implement.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/plan.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/playbook.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/specify.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/commands/tasks.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/scaffold/.blitz/memory/playbook.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/scaffold/.blitz/templates/plan-template.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1/blitz_cli/playbooks/scaffold}/.blitz/templates/spec-template.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/scaffold/.blitz/templates/tasks-template.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/workspace/__init__.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/playbooks/workspace/huddle.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/__init__.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/locker_room_Makefile.tmpl +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/locker_room_README.md.tmpl +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/blitz_cli/templates/workspace_playbook.md.tmpl +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/checklists/requirements.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/contracts/cli-commands.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/plan.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/quickstart.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/research.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/spec.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/001-cli-model-namespace/tasks.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/checklists/requirements.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/contracts/experimental-labels.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/data-model.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/plan.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/quickstart.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/research.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/spec.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/002-label-experimental-surfaces/tasks.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/checklists/requirements.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/contracts/cli-output.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/data-model.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/plan.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/quickstart.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/research.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/spec.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/003-init-gitignore-rules/tasks.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/checklists/requirements.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/contracts/cli.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/contracts/export-format.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/data-model.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/plan.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/quickstart.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/research.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/spec.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/004-dpo-collect-pairs/tasks.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/checklists/requirements.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/contracts/client-sink.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/contracts/ingest-api.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/data-model.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/plan.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/quickstart.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/research.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/spec.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/specs/005-collect-ingest-endpoint/tasks.md +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_collect.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_collect_http.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_collect_server.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_execute.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_make.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_ontology.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_playbooks.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_redact.py +0 -0
- {blitz_cli-0.15.0 → blitz_cli-0.16.1}/tests/test_tool_schemas.py +0 -0
|
@@ -21,3 +21,10 @@ dist/
|
|
|
21
21
|
# the blitz.toml, which is not always the repo root. The `*` covers the SQLite
|
|
22
22
|
# -wal/-shm sidecars.
|
|
23
23
|
**/.blitz/traces.db*
|
|
24
|
+
|
|
25
|
+
# Regenerable `blitz init` output: scripts/ and templates/ are refreshed from the
|
|
26
|
+
# package on --force, so a clone reproduces them exactly by running init.
|
|
27
|
+
# memory/playbook.md is deliberately NOT here: it is seeded once and never
|
|
28
|
+
# overwritten, so it stays tracked.
|
|
29
|
+
/.blitz/scripts/
|
|
30
|
+
/.blitz/templates/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: blitz-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.1
|
|
4
4
|
Summary: Developer CLI for Blitz locker-room: spec-driven playbooks, a codebase/huddle ontology graph with an MCP tool, and the composable watcher/trainer/graph containers.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -21,6 +21,9 @@ from pathlib import Path
|
|
|
21
21
|
from typing import Optional
|
|
22
22
|
|
|
23
23
|
_SYSTEM_REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>\s*", re.DOTALL)
|
|
24
|
+
# Slash-command invocations recorded inside the raw user prompt, e.g.
|
|
25
|
+
# <command-name>/huddle</command-name>. Matched BEFORE _clean_prompt() runs.
|
|
26
|
+
_COMMAND_NAME_RE = re.compile(r"<command-name>\s*(/?[\w:-]+)\s*</command-name>")
|
|
24
27
|
# Compact, single-line caps for the terminal's quick-glance labels.
|
|
25
28
|
_MAX_SUMMARY_LEN = 80
|
|
26
29
|
_MAX_SNIPPET_LEN = 200
|
|
@@ -57,6 +60,9 @@ class Turn:
|
|
|
57
60
|
cache_creation_tokens: int = 0
|
|
58
61
|
models: list = field(default_factory=list)
|
|
59
62
|
turn_uuid: Optional[str] = None
|
|
63
|
+
commands: list = field(default_factory=list)
|
|
64
|
+
did_git_push: bool = False
|
|
65
|
+
did_create_pr: bool = False
|
|
60
66
|
|
|
61
67
|
|
|
62
68
|
@dataclass
|
|
@@ -198,6 +204,18 @@ def _prompt_text(content) -> Optional[str]:
|
|
|
198
204
|
return None
|
|
199
205
|
|
|
200
206
|
|
|
207
|
+
def _raw_prompt_text(content) -> str:
|
|
208
|
+
"""The user record's prompt text with nothing stripped, for extraction
|
|
209
|
+
of markers (like <command-name> blocks) that _clean_prompt may remove."""
|
|
210
|
+
if isinstance(content, str):
|
|
211
|
+
return content
|
|
212
|
+
if isinstance(content, list):
|
|
213
|
+
return "\n".join(
|
|
214
|
+
b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"
|
|
215
|
+
)
|
|
216
|
+
return ""
|
|
217
|
+
|
|
218
|
+
|
|
201
219
|
def _truncate(text: str, limit: int) -> str:
|
|
202
220
|
"""Collapse whitespace to one line and cap length — for compact labels."""
|
|
203
221
|
text = " ".join(text.split())
|
|
@@ -520,6 +538,12 @@ def parse_session(path: Path, file_history_dir: Optional[Path] = None) -> Sessio
|
|
|
520
538
|
if prompt is not None:
|
|
521
539
|
_finalize()
|
|
522
540
|
current = Turn(timestamp=rec.get("timestamp"), prompt=prompt, turn_uuid=rec.get("uuid"))
|
|
541
|
+
# Slash commands are extracted from the RAW prompt text,
|
|
542
|
+
# before _clean_prompt strips anything (research R1).
|
|
543
|
+
current.commands = [
|
|
544
|
+
name if name.startswith("/") else f"/{name}"
|
|
545
|
+
for name in _COMMAND_NAME_RE.findall(_raw_prompt_text(content))
|
|
546
|
+
]
|
|
523
547
|
continue
|
|
524
548
|
# tool-result line: match each block back to its tool_use action
|
|
525
549
|
if isinstance(content, list):
|
|
@@ -574,6 +598,12 @@ def parse_session(path: Path, file_history_dir: Optional[Path] = None) -> Sessio
|
|
|
574
598
|
file_history_dir, session_id, tool_name, tool_input or {},
|
|
575
599
|
file_path, backup_info["backupFileName"],
|
|
576
600
|
)
|
|
601
|
+
if tool_name == "Bash":
|
|
602
|
+
command = (tool_input or {}).get("command") or ""
|
|
603
|
+
if "git push" in command:
|
|
604
|
+
current.did_git_push = True
|
|
605
|
+
if "gh pr create" in command:
|
|
606
|
+
current.did_create_pr = True
|
|
577
607
|
current.actions.append(action)
|
|
578
608
|
if block.get("id"):
|
|
579
609
|
pending_tool_uses[block["id"]] = action
|
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Convert Claude Code activity (parsed by `_activity.py`) into a fine-tuning
|
|
2
|
+
corpus stored in SQLite, for the `blitz locker-room watch`
|
|
3
|
+
container.
|
|
4
|
+
|
|
5
|
+
This is a wholly separate store from `_local.py`'s `spans` table —
|
|
6
|
+
`_dataset.py:span_to_example()` explicitly rejects any tool-call-shaped span
|
|
7
|
+
and only extracts plain-string message content, so it's the wrong shape for
|
|
8
|
+
agentic Claude Code trajectories. `turns` here holds one row per Turn (an
|
|
9
|
+
OpenAI/Qwen-chat-style `{"messages": [...]}` example using Claude Code's own
|
|
10
|
+
native tool names), and `raw_sessions` holds a redacted archival copy of each
|
|
11
|
+
session's full transcript — kept independent of Claude Code's own
|
|
12
|
+
`cleanupPeriodDays` auto-deletion (default 30 days), and independent of our
|
|
13
|
+
own lossy `turns` conversion, so a better conversion can be re-derived later
|
|
14
|
+
without needing the original (possibly already-deleted) source transcript.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sqlite3
|
|
21
|
+
import tempfile
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from blitz_cli import _activity, _redact
|
|
27
|
+
|
|
28
|
+
_SCHEMA = """
|
|
29
|
+
CREATE TABLE IF NOT EXISTS turns (
|
|
30
|
+
turn_id TEXT PRIMARY KEY,
|
|
31
|
+
session_id TEXT NOT NULL,
|
|
32
|
+
project_path TEXT,
|
|
33
|
+
turn_uuid TEXT,
|
|
34
|
+
turn_ordinal INTEGER,
|
|
35
|
+
started_at TEXT,
|
|
36
|
+
duration_ms INTEGER,
|
|
37
|
+
models TEXT,
|
|
38
|
+
input_tokens INTEGER,
|
|
39
|
+
output_tokens INTEGER,
|
|
40
|
+
cache_read_tokens INTEGER,
|
|
41
|
+
cache_creation_tokens INTEGER,
|
|
42
|
+
action_count INTEGER,
|
|
43
|
+
had_error INTEGER,
|
|
44
|
+
messages_json TEXT NOT NULL,
|
|
45
|
+
ingested_at TEXT NOT NULL,
|
|
46
|
+
source_mtime REAL NOT NULL,
|
|
47
|
+
commands_json TEXT,
|
|
48
|
+
did_git_push INTEGER,
|
|
49
|
+
did_create_pr INTEGER
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_turns_session_id ON turns(session_id);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_turns_started_at ON turns(started_at);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_turns_project_path ON turns(project_path);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS ingest_state (
|
|
56
|
+
session_path TEXT PRIMARY KEY,
|
|
57
|
+
last_mtime REAL NOT NULL,
|
|
58
|
+
last_ingested_at TEXT NOT NULL
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
CREATE TABLE IF NOT EXISTS raw_sessions (
|
|
62
|
+
session_id TEXT PRIMARY KEY,
|
|
63
|
+
project_path TEXT,
|
|
64
|
+
raw_content TEXT NOT NULL,
|
|
65
|
+
source_mtime REAL NOT NULL,
|
|
66
|
+
ingested_at TEXT NOT NULL
|
|
67
|
+
);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_raw_sessions_project_path ON raw_sessions(project_path);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS local_usage (
|
|
71
|
+
run_id TEXT PRIMARY KEY,
|
|
72
|
+
repo_path TEXT,
|
|
73
|
+
spec TEXT,
|
|
74
|
+
model TEXT,
|
|
75
|
+
input_tokens INTEGER,
|
|
76
|
+
output_tokens INTEGER,
|
|
77
|
+
started_at TEXT,
|
|
78
|
+
source_path TEXT,
|
|
79
|
+
ingested_at TEXT
|
|
80
|
+
);
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_local_usage_started_at ON local_usage(started_at);
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
# Additive v2 columns on `turns`, applied by ALTER TABLE when opening a db
|
|
85
|
+
# created before they existed (CREATE TABLE IF NOT EXISTS never adds columns).
|
|
86
|
+
_TURNS_V2_COLUMNS = (
|
|
87
|
+
("commands_json", "TEXT"),
|
|
88
|
+
("did_git_push", "INTEGER"),
|
|
89
|
+
("did_create_pr", "INTEGER"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _now_iso() -> str:
|
|
94
|
+
return datetime.now(timezone.utc).isoformat()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def turn_to_messages(turn) -> Optional[dict]:
|
|
98
|
+
"""Turn -> {"messages": [...]} training example: a user message
|
|
99
|
+
(turn.prompt), one assistant message carrying every action's tool_calls
|
|
100
|
+
(native Claude Code tool names — Read/Edit/Write/Bash/... — not remapped),
|
|
101
|
+
one tool-role message per result, then a final assistant text message.
|
|
102
|
+
All text is redacted. Returns None if the turn has no usable content at
|
|
103
|
+
all (empty prompt, no actions, no response — a dangling trailing turn)."""
|
|
104
|
+
if not turn.prompt and not turn.response and not turn.actions:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
messages = [{"role": "user", "content": _redact.redact(turn.prompt or "")}]
|
|
108
|
+
|
|
109
|
+
if turn.actions:
|
|
110
|
+
tool_calls = []
|
|
111
|
+
for i, action in enumerate(turn.actions):
|
|
112
|
+
arguments = json.dumps(
|
|
113
|
+
_redact.redact_json_strings(action.tool_input or {}), ensure_ascii=False
|
|
114
|
+
)
|
|
115
|
+
tool_calls.append({
|
|
116
|
+
"id": f"call_{i}",
|
|
117
|
+
"type": "function",
|
|
118
|
+
"function": {"name": action.tool, "arguments": arguments},
|
|
119
|
+
})
|
|
120
|
+
messages.append({"role": "assistant", "content": None, "tool_calls": tool_calls})
|
|
121
|
+
for i, action in enumerate(turn.actions):
|
|
122
|
+
messages.append({
|
|
123
|
+
"role": "tool",
|
|
124
|
+
"tool_call_id": f"call_{i}",
|
|
125
|
+
"content": _redact.redact(action.result_full or ""),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if turn.response:
|
|
129
|
+
messages.append({"role": "assistant", "content": _redact.redact(turn.response)})
|
|
130
|
+
|
|
131
|
+
return {"messages": messages}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def turn_to_row(
|
|
135
|
+
session_id: str, project_path: str, ordinal: int, turn, source_mtime: float
|
|
136
|
+
) -> Optional[dict]:
|
|
137
|
+
"""Build the full `turns` table row for one Turn, or None if it has no
|
|
138
|
+
usable content (see turn_to_messages)."""
|
|
139
|
+
example = turn_to_messages(turn)
|
|
140
|
+
if example is None:
|
|
141
|
+
return None
|
|
142
|
+
turn_key = turn.turn_uuid or f"ordinal-{ordinal}"
|
|
143
|
+
return {
|
|
144
|
+
"turn_id": f"{session_id}:{turn_key}",
|
|
145
|
+
"session_id": session_id,
|
|
146
|
+
"project_path": project_path,
|
|
147
|
+
"turn_uuid": turn.turn_uuid,
|
|
148
|
+
"turn_ordinal": ordinal,
|
|
149
|
+
"started_at": turn.timestamp,
|
|
150
|
+
"duration_ms": turn.duration_ms,
|
|
151
|
+
"models": json.dumps(turn.models),
|
|
152
|
+
"input_tokens": turn.input_tokens,
|
|
153
|
+
"output_tokens": turn.output_tokens,
|
|
154
|
+
"cache_read_tokens": turn.cache_read_tokens,
|
|
155
|
+
"cache_creation_tokens": turn.cache_creation_tokens,
|
|
156
|
+
"action_count": len(turn.actions),
|
|
157
|
+
"had_error": 1 if any(a.ok is False for a in turn.actions) else 0,
|
|
158
|
+
"messages_json": json.dumps(example, ensure_ascii=False),
|
|
159
|
+
"ingested_at": _now_iso(),
|
|
160
|
+
"source_mtime": source_mtime,
|
|
161
|
+
"commands_json": json.dumps(turn.commands),
|
|
162
|
+
"did_git_push": 1 if turn.did_git_push else 0,
|
|
163
|
+
"did_create_pr": 1 if turn.did_create_pr else 0,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TurnStore:
|
|
168
|
+
"""Writer for the locker-room activity fine-tuning corpus. Unlike _local.py's
|
|
169
|
+
read-only LocalStore (which assumes an externally-stubbed db and returns
|
|
170
|
+
None if missing), this store owns its schema and creates it on first
|
|
171
|
+
connect — it's the one and only writer, structurally closer to
|
|
172
|
+
serve.py.tmpl's _init_db() than to a CLI-side read path."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, db_path: Path):
|
|
175
|
+
self._db_path = Path(db_path)
|
|
176
|
+
self._schema_ready = False
|
|
177
|
+
|
|
178
|
+
def _connect(self) -> sqlite3.Connection:
|
|
179
|
+
db = str(self._db_path)
|
|
180
|
+
is_uri = db.startswith("file:")
|
|
181
|
+
if not is_uri:
|
|
182
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
conn = sqlite3.connect(db, uri=is_uri)
|
|
184
|
+
conn.row_factory = sqlite3.Row
|
|
185
|
+
if not self._schema_ready:
|
|
186
|
+
try:
|
|
187
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
188
|
+
conn.executescript(_SCHEMA)
|
|
189
|
+
existing = {
|
|
190
|
+
row[1] for row in conn.execute("PRAGMA table_info(turns)")
|
|
191
|
+
}
|
|
192
|
+
for name, col_type in _TURNS_V2_COLUMNS:
|
|
193
|
+
if name not in existing:
|
|
194
|
+
conn.execute(f"ALTER TABLE turns ADD COLUMN {name} {col_type}")
|
|
195
|
+
conn.commit()
|
|
196
|
+
except sqlite3.OperationalError:
|
|
197
|
+
# Read-only database: readers need no DDL; proceed without it.
|
|
198
|
+
pass
|
|
199
|
+
self._schema_ready = True
|
|
200
|
+
return conn
|
|
201
|
+
|
|
202
|
+
def get_last_mtime(self, session_path: str) -> Optional[float]:
|
|
203
|
+
conn = self._connect()
|
|
204
|
+
try:
|
|
205
|
+
row = conn.execute(
|
|
206
|
+
"SELECT last_mtime FROM ingest_state WHERE session_path = ?", (session_path,)
|
|
207
|
+
).fetchone()
|
|
208
|
+
return row["last_mtime"] if row else None
|
|
209
|
+
finally:
|
|
210
|
+
conn.close()
|
|
211
|
+
|
|
212
|
+
def set_last_mtime(self, session_path: str, mtime: float) -> None:
|
|
213
|
+
conn = self._connect()
|
|
214
|
+
try:
|
|
215
|
+
conn.execute(
|
|
216
|
+
"INSERT OR REPLACE INTO ingest_state (session_path, last_mtime, last_ingested_at) "
|
|
217
|
+
"VALUES (?, ?, ?)",
|
|
218
|
+
(session_path, mtime, _now_iso()),
|
|
219
|
+
)
|
|
220
|
+
conn.commit()
|
|
221
|
+
finally:
|
|
222
|
+
conn.close()
|
|
223
|
+
|
|
224
|
+
def upsert_turns(self, rows: list) -> int:
|
|
225
|
+
if not rows:
|
|
226
|
+
return 0
|
|
227
|
+
# NULL in the v2 columns means "not derived" (contract C1); callers
|
|
228
|
+
# predating v2 may omit the keys entirely.
|
|
229
|
+
rows = [
|
|
230
|
+
{"commands_json": None, "did_git_push": None, "did_create_pr": None, **row}
|
|
231
|
+
for row in rows
|
|
232
|
+
]
|
|
233
|
+
conn = self._connect()
|
|
234
|
+
try:
|
|
235
|
+
conn.executemany(
|
|
236
|
+
"INSERT OR REPLACE INTO turns ("
|
|
237
|
+
"turn_id, session_id, project_path, turn_uuid, turn_ordinal, started_at, "
|
|
238
|
+
"duration_ms, models, input_tokens, output_tokens, cache_read_tokens, "
|
|
239
|
+
"cache_creation_tokens, action_count, had_error, messages_json, ingested_at, "
|
|
240
|
+
"source_mtime, commands_json, did_git_push, did_create_pr"
|
|
241
|
+
") VALUES ("
|
|
242
|
+
":turn_id, :session_id, :project_path, :turn_uuid, :turn_ordinal, :started_at, "
|
|
243
|
+
":duration_ms, :models, :input_tokens, :output_tokens, :cache_read_tokens, "
|
|
244
|
+
":cache_creation_tokens, :action_count, :had_error, :messages_json, :ingested_at, "
|
|
245
|
+
":source_mtime, :commands_json, :did_git_push, :did_create_pr"
|
|
246
|
+
")",
|
|
247
|
+
rows,
|
|
248
|
+
)
|
|
249
|
+
conn.commit()
|
|
250
|
+
return len(rows)
|
|
251
|
+
finally:
|
|
252
|
+
conn.close()
|
|
253
|
+
|
|
254
|
+
def upsert_local_usage(self, rows: list) -> int:
|
|
255
|
+
if not rows:
|
|
256
|
+
return 0
|
|
257
|
+
conn = self._connect()
|
|
258
|
+
try:
|
|
259
|
+
conn.executemany(
|
|
260
|
+
"INSERT OR REPLACE INTO local_usage ("
|
|
261
|
+
"run_id, repo_path, spec, model, input_tokens, output_tokens, "
|
|
262
|
+
"started_at, source_path, ingested_at"
|
|
263
|
+
") VALUES ("
|
|
264
|
+
":run_id, :repo_path, :spec, :model, :input_tokens, :output_tokens, "
|
|
265
|
+
":started_at, :source_path, :ingested_at"
|
|
266
|
+
")",
|
|
267
|
+
rows,
|
|
268
|
+
)
|
|
269
|
+
conn.commit()
|
|
270
|
+
return len(rows)
|
|
271
|
+
finally:
|
|
272
|
+
conn.close()
|
|
273
|
+
|
|
274
|
+
def usage_summary(self, from_ts: str, to_ts: str, bucket: str = "hour") -> dict:
|
|
275
|
+
"""Token usage over time by (bucket, source, model). Window:
|
|
276
|
+
from_ts <= started_at < to_ts (ISO-8601 UTC strings). Frontier rows
|
|
277
|
+
come from `turns` (tokens attributed to the turn's first model),
|
|
278
|
+
local rows from `local_usage`. Returns {"series": [...]} ordered by
|
|
279
|
+
bucket. Safe on a read-only database (SELECT only)."""
|
|
280
|
+
formats = {"hour": "%Y-%m-%dT%H:00:00Z", "day": "%Y-%m-%dT00:00:00Z"}
|
|
281
|
+
if bucket not in formats:
|
|
282
|
+
raise ValueError(f"bucket must be 'hour' or 'day', got {bucket!r}")
|
|
283
|
+
fmt = formats[bucket]
|
|
284
|
+
conn = self._connect()
|
|
285
|
+
try:
|
|
286
|
+
series = []
|
|
287
|
+
frontier = conn.execute(
|
|
288
|
+
"SELECT strftime(?, started_at) AS bucket, "
|
|
289
|
+
"COALESCE(json_extract(models, '$[0]'), '') AS model, "
|
|
290
|
+
"SUM(COALESCE(input_tokens, 0)) AS input_tokens, "
|
|
291
|
+
"SUM(COALESCE(output_tokens, 0)) AS output_tokens "
|
|
292
|
+
"FROM turns WHERE started_at >= ? AND started_at < ? "
|
|
293
|
+
"GROUP BY bucket, model",
|
|
294
|
+
(fmt, from_ts, to_ts),
|
|
295
|
+
).fetchall()
|
|
296
|
+
for row in frontier:
|
|
297
|
+
series.append({
|
|
298
|
+
"bucket": row["bucket"], "source": "frontier", "model": row["model"],
|
|
299
|
+
"input_tokens": row["input_tokens"], "output_tokens": row["output_tokens"],
|
|
300
|
+
})
|
|
301
|
+
try:
|
|
302
|
+
local = conn.execute(
|
|
303
|
+
"SELECT strftime(?, started_at) AS bucket, "
|
|
304
|
+
"COALESCE(model, '') AS model, "
|
|
305
|
+
"SUM(COALESCE(input_tokens, 0)) AS input_tokens, "
|
|
306
|
+
"SUM(COALESCE(output_tokens, 0)) AS output_tokens "
|
|
307
|
+
"FROM local_usage WHERE started_at >= ? AND started_at < ? "
|
|
308
|
+
"GROUP BY bucket, model",
|
|
309
|
+
(fmt, from_ts, to_ts),
|
|
310
|
+
).fetchall()
|
|
311
|
+
except sqlite3.OperationalError:
|
|
312
|
+
# Read-only pre-v2 db without local_usage: no local rows.
|
|
313
|
+
local = []
|
|
314
|
+
for row in local:
|
|
315
|
+
series.append({
|
|
316
|
+
"bucket": row["bucket"], "source": "local", "model": row["model"],
|
|
317
|
+
"input_tokens": row["input_tokens"], "output_tokens": row["output_tokens"],
|
|
318
|
+
})
|
|
319
|
+
series.sort(key=lambda s: (s["bucket"], s["source"], s["model"]))
|
|
320
|
+
return {"series": series}
|
|
321
|
+
finally:
|
|
322
|
+
conn.close()
|
|
323
|
+
|
|
324
|
+
def usage_sessions(self, from_ts: str, to_ts: str) -> dict:
|
|
325
|
+
"""Per-session aggregates over turns in the window. `models` and
|
|
326
|
+
`commands` are merged across turns, deduped in first-seen order;
|
|
327
|
+
flags are true when any turn set them; NULL commands_json/flags are
|
|
328
|
+
treated as empty/false. Returns {"sessions": [...]} ordered by
|
|
329
|
+
first_ts. Safe on a read-only database (SELECT only)."""
|
|
330
|
+
conn = self._connect()
|
|
331
|
+
try:
|
|
332
|
+
rows = conn.execute(
|
|
333
|
+
"SELECT * FROM turns WHERE started_at >= ? AND started_at < ? "
|
|
334
|
+
"ORDER BY started_at",
|
|
335
|
+
(from_ts, to_ts),
|
|
336
|
+
).fetchall()
|
|
337
|
+
finally:
|
|
338
|
+
conn.close()
|
|
339
|
+
|
|
340
|
+
sessions: dict = {}
|
|
341
|
+
order = []
|
|
342
|
+
for row in rows:
|
|
343
|
+
keys = row.keys()
|
|
344
|
+
sid = row["session_id"]
|
|
345
|
+
agg = sessions.get(sid)
|
|
346
|
+
if agg is None:
|
|
347
|
+
agg = {
|
|
348
|
+
"session_id": sid,
|
|
349
|
+
"project_path": row["project_path"],
|
|
350
|
+
"first_ts": row["started_at"],
|
|
351
|
+
"last_ts": row["started_at"],
|
|
352
|
+
"input_tokens": 0, "output_tokens": 0,
|
|
353
|
+
"cache_read_tokens": 0, "cache_creation_tokens": 0,
|
|
354
|
+
"models": [], "commands": [],
|
|
355
|
+
"did_git_push": False, "did_create_pr": False,
|
|
356
|
+
"turn_count": 0, "error_count": 0,
|
|
357
|
+
}
|
|
358
|
+
sessions[sid] = agg
|
|
359
|
+
order.append(sid)
|
|
360
|
+
ts = row["started_at"]
|
|
361
|
+
if ts is not None:
|
|
362
|
+
if agg["first_ts"] is None or ts < agg["first_ts"]:
|
|
363
|
+
agg["first_ts"] = ts
|
|
364
|
+
if agg["last_ts"] is None or ts > agg["last_ts"]:
|
|
365
|
+
agg["last_ts"] = ts
|
|
366
|
+
for col in ("input_tokens", "output_tokens", "cache_read_tokens", "cache_creation_tokens"):
|
|
367
|
+
agg[col] += row[col] or 0
|
|
368
|
+
agg["turn_count"] += 1
|
|
369
|
+
agg["error_count"] += 1 if row["had_error"] else 0
|
|
370
|
+
try:
|
|
371
|
+
models = json.loads(row["models"]) if row["models"] else []
|
|
372
|
+
except ValueError:
|
|
373
|
+
models = []
|
|
374
|
+
for m in models:
|
|
375
|
+
if m not in agg["models"]:
|
|
376
|
+
agg["models"].append(m)
|
|
377
|
+
commands_json = row["commands_json"] if "commands_json" in keys else None
|
|
378
|
+
try:
|
|
379
|
+
commands = json.loads(commands_json) if commands_json else []
|
|
380
|
+
except ValueError:
|
|
381
|
+
commands = []
|
|
382
|
+
for c in commands:
|
|
383
|
+
if c not in agg["commands"]:
|
|
384
|
+
agg["commands"].append(c)
|
|
385
|
+
if "did_git_push" in keys and row["did_git_push"]:
|
|
386
|
+
agg["did_git_push"] = True
|
|
387
|
+
if "did_create_pr" in keys and row["did_create_pr"]:
|
|
388
|
+
agg["did_create_pr"] = True
|
|
389
|
+
|
|
390
|
+
result = sorted(
|
|
391
|
+
(sessions[sid] for sid in order),
|
|
392
|
+
key=lambda s: s["first_ts"] or "",
|
|
393
|
+
)
|
|
394
|
+
return {"sessions": result}
|
|
395
|
+
|
|
396
|
+
def upsert_raw_session(
|
|
397
|
+
self, session_id: str, project_path: str, raw_content: str, source_mtime: float
|
|
398
|
+
) -> None:
|
|
399
|
+
conn = self._connect()
|
|
400
|
+
try:
|
|
401
|
+
conn.execute(
|
|
402
|
+
"INSERT OR REPLACE INTO raw_sessions "
|
|
403
|
+
"(session_id, project_path, raw_content, source_mtime, ingested_at) "
|
|
404
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
405
|
+
(session_id, project_path, raw_content, source_mtime, _now_iso()),
|
|
406
|
+
)
|
|
407
|
+
conn.commit()
|
|
408
|
+
finally:
|
|
409
|
+
conn.close()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def ingest_once(projects_dir: Path, store: TurnStore, print_fn=print) -> int:
|
|
413
|
+
"""Walk every project dir under projects_dir, re-ingest any session whose
|
|
414
|
+
mtime has advanced since the last recorded ingest, and return the number
|
|
415
|
+
of turn rows upserted this pass. A session file's raw (redacted) content
|
|
416
|
+
is archived to `raw_sessions` on the same pass. Never raises on a single
|
|
417
|
+
bad file — logs and continues, since a transcript can be mid-write."""
|
|
418
|
+
total = 0
|
|
419
|
+
if not projects_dir.is_dir():
|
|
420
|
+
return total
|
|
421
|
+
for project_dir in sorted(projects_dir.iterdir()):
|
|
422
|
+
if not project_dir.is_dir():
|
|
423
|
+
continue
|
|
424
|
+
for session_path in sorted(project_dir.glob("*.jsonl")):
|
|
425
|
+
try:
|
|
426
|
+
mtime = session_path.stat().st_mtime
|
|
427
|
+
except OSError:
|
|
428
|
+
continue
|
|
429
|
+
last = store.get_last_mtime(str(session_path))
|
|
430
|
+
if last is not None and mtime <= last:
|
|
431
|
+
continue
|
|
432
|
+
try:
|
|
433
|
+
raw_text = session_path.read_text(encoding="utf-8", errors="replace")
|
|
434
|
+
session = _activity.parse_session(session_path)
|
|
435
|
+
except Exception as exc: # a malformed/mid-write file must never kill the watcher
|
|
436
|
+
print_fn(f"[watch] skip {session_path}: {exc}")
|
|
437
|
+
continue
|
|
438
|
+
|
|
439
|
+
store.upsert_raw_session(
|
|
440
|
+
session.id, project_dir.name, _redact.redact_jsonl_text(raw_text), mtime
|
|
441
|
+
)
|
|
442
|
+
rows = []
|
|
443
|
+
for i, turn in enumerate(session.turns):
|
|
444
|
+
row = turn_to_row(session.id, project_dir.name, i, turn, mtime)
|
|
445
|
+
if row is not None:
|
|
446
|
+
rows.append(row)
|
|
447
|
+
total += store.upsert_turns(rows)
|
|
448
|
+
store.set_last_mtime(str(session_path), mtime)
|
|
449
|
+
return total
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def backfill_turn_usage(store: TurnStore, print_fn=print) -> int:
|
|
453
|
+
"""Re-derive commands_json/did_git_push/did_create_pr for existing turns
|
|
454
|
+
rows by re-parsing raw_sessions.raw_content with the same parser ingest
|
|
455
|
+
uses. Idempotent (parsing is deterministic). Unmatched turn ids skip
|
|
456
|
+
silently; malformed raw content logs via print_fn and continues. Returns
|
|
457
|
+
the number of rows updated."""
|
|
458
|
+
conn = store._connect()
|
|
459
|
+
try:
|
|
460
|
+
raw_rows = conn.execute("SELECT session_id, raw_content FROM raw_sessions").fetchall()
|
|
461
|
+
updated = 0
|
|
462
|
+
for raw in raw_rows:
|
|
463
|
+
session_id = raw["session_id"]
|
|
464
|
+
try:
|
|
465
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
466
|
+
session_path = Path(tmp_dir) / f"{session_id}.jsonl"
|
|
467
|
+
session_path.write_text(raw["raw_content"], encoding="utf-8")
|
|
468
|
+
session = _activity.parse_session(session_path)
|
|
469
|
+
except Exception as exc: # malformed archives must not kill the backfill
|
|
470
|
+
print_fn(f"[backfill] skip {session_id}: {exc}")
|
|
471
|
+
continue
|
|
472
|
+
for i, turn in enumerate(session.turns):
|
|
473
|
+
turn_key = turn.turn_uuid or f"ordinal-{i}"
|
|
474
|
+
cursor = conn.execute(
|
|
475
|
+
"UPDATE turns SET commands_json = ?, did_git_push = ?, did_create_pr = ? "
|
|
476
|
+
"WHERE turn_id = ?",
|
|
477
|
+
(
|
|
478
|
+
json.dumps(turn.commands),
|
|
479
|
+
1 if turn.did_git_push else 0,
|
|
480
|
+
1 if turn.did_create_pr else 0,
|
|
481
|
+
f"{session_id}:{turn_key}",
|
|
482
|
+
),
|
|
483
|
+
)
|
|
484
|
+
updated += cursor.rowcount
|
|
485
|
+
conn.commit()
|
|
486
|
+
return updated
|
|
487
|
+
finally:
|
|
488
|
+
conn.close()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def ingest_local_usage(workspace_root: Path, store: TurnStore, print_fn=print) -> int:
|
|
492
|
+
"""Scan the workspace's repos (up to three directory levels deep, matching
|
|
493
|
+
huddle discovery) for .blitz/execute/runs/*/usage.json and upsert one
|
|
494
|
+
local_usage row per run, keyed on "<repo>:<run-dir>" so the pass is
|
|
495
|
+
idempotent. Missing root or malformed records never raise. Returns the
|
|
496
|
+
number of rows upserted."""
|
|
497
|
+
workspace_root = Path(workspace_root)
|
|
498
|
+
if not workspace_root.is_dir():
|
|
499
|
+
return 0
|
|
500
|
+
usage_paths = sorted(
|
|
501
|
+
p
|
|
502
|
+
for depth in ("*", "*/*", "*/*/*")
|
|
503
|
+
for p in workspace_root.glob(f"{depth}/.blitz/execute/runs/*/usage.json")
|
|
504
|
+
)
|
|
505
|
+
rows = []
|
|
506
|
+
for usage_path in usage_paths:
|
|
507
|
+
try:
|
|
508
|
+
record = json.loads(usage_path.read_text())
|
|
509
|
+
except (OSError, ValueError) as exc:
|
|
510
|
+
print_fn(f"[local-usage] skip {usage_path}: {exc}")
|
|
511
|
+
continue
|
|
512
|
+
spec = record.get("spec")
|
|
513
|
+
total = record.get("total")
|
|
514
|
+
if not spec or not isinstance(spec, str) or not isinstance(total, dict):
|
|
515
|
+
continue
|
|
516
|
+
run_dir = usage_path.parent
|
|
517
|
+
repo_dir = usage_path.parents[4] # <repo>/.blitz/execute/runs/<run>/usage.json
|
|
518
|
+
try:
|
|
519
|
+
started_local = datetime.strptime(run_dir.name, "%Y%m%d-%H%M%S").astimezone()
|
|
520
|
+
started_utc = started_local.astimezone(timezone.utc)
|
|
521
|
+
except ValueError:
|
|
522
|
+
try:
|
|
523
|
+
mtime = usage_path.stat().st_mtime
|
|
524
|
+
except OSError:
|
|
525
|
+
continue
|
|
526
|
+
started_utc = datetime.fromtimestamp(mtime, timezone.utc)
|
|
527
|
+
# "Z" suffix matches the timestamp format Claude Code transcripts use,
|
|
528
|
+
# so lexicographic window comparisons stay chronological across sources.
|
|
529
|
+
started_at = started_utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
530
|
+
repo_rel = repo_dir.relative_to(workspace_root).as_posix()
|
|
531
|
+
rows.append({
|
|
532
|
+
"run_id": f"{repo_rel}:{run_dir.name}",
|
|
533
|
+
"repo_path": repo_rel,
|
|
534
|
+
"spec": spec,
|
|
535
|
+
"model": record.get("model"),
|
|
536
|
+
"input_tokens": total.get("input", 0),
|
|
537
|
+
"output_tokens": total.get("output", 0),
|
|
538
|
+
"started_at": started_at,
|
|
539
|
+
"source_path": str(usage_path),
|
|
540
|
+
"ingested_at": _now_iso(),
|
|
541
|
+
})
|
|
542
|
+
return store.upsert_local_usage(rows)
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Reconcile an install target's .gitignore with the playbook working area rules.
|
|
2
2
|
|
|
3
|
-
The .blitz/ directory splits into
|
|
4
|
-
scripts/, templates/) and per-run state
|
|
5
|
-
feature.json, and the blitz-sdk trace store)
|
|
6
|
-
|
|
7
|
-
never
|
|
3
|
+
The .blitz/ directory splits into what a clone can reproduce and what it cannot.
|
|
4
|
+
Regenerable output (scripts/, templates/) and per-run state (execute/,
|
|
5
|
+
feature.json, and the blitz-sdk trace store) are ignored. Seeded-once content
|
|
6
|
+
(memory/, which holds the repo's playbook) stays trackable, because
|
|
7
|
+
install_scaffold() never overwrites it and so nothing can reconstruct it.
|
|
8
|
+
|
|
9
|
+
This module ensures the ignored half's rules are present in the target's root
|
|
10
|
+
.gitignore, append-only, and never touches a rule the user wrote.
|
|
8
11
|
"""
|
|
9
12
|
|
|
10
13
|
from __future__ import annotations
|
|
@@ -14,7 +17,7 @@ from pathlib import Path
|
|
|
14
17
|
|
|
15
18
|
from blitz_cli._git import _git
|
|
16
19
|
|
|
17
|
-
COMMENT = "# Blitz
|
|
20
|
+
COMMENT = "# Blitz working area: regenerable output and per-run state."
|
|
18
21
|
|
|
19
22
|
RULES: tuple[str, ...] = (
|
|
20
23
|
"/.blitz/execute/",
|
|
@@ -24,6 +27,16 @@ RULES: tuple[str, ...] = (
|
|
|
24
27
|
# which is not always the repo root. An anchored rule would silently miss
|
|
25
28
|
# it. The trailing * covers the SQLite -wal and -shm sidecars.
|
|
26
29
|
"**/.blitz/traces.db*",
|
|
30
|
+
# Regenerable output: install_scaffold() refreshes both from the package
|
|
31
|
+
# under --force, and nothing detects on either, so a clone reproduces them
|
|
32
|
+
# exactly by running init. The test is regenerable AND not an input to the
|
|
33
|
+
# thing that regenerates it. memory/ fails the first half: it is seeded
|
|
34
|
+
# once and never overwritten, so nothing can reconstruct it. A rendered
|
|
35
|
+
# agent command directory fails the second: it is what AGENTS[...] detects
|
|
36
|
+
# on, so ignoring it means init finds no agent and installs nothing.
|
|
37
|
+
# Neither belongs here. See specs/006-init-ignore-generated/spec.md.
|
|
38
|
+
"/.blitz/scripts/",
|
|
39
|
+
"/.blitz/templates/",
|
|
27
40
|
)
|
|
28
41
|
|
|
29
42
|
|