mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-10-09 03:17:59 +00:00
Add CLI for agent and programmatic access
Introduce a thin Python CLI that wraps the backend REST API. Seven commands: space list, space create, state, checkpoint create, checkpoint show, checkpoint list, checkpoint review. Reads piped JSON on stdin for checkpoint create, prints a continuation-oriented markdown brief for state. Supports --json on every command for structured output. Fixes a V2 schema drift where the commit response omitted assumptions and artifacts, so the CLI can read full checkpoints via the cleaner V2 single-resource endpoints. Updates README, ARCHITECTURE, and DECISIONS to frame Smriti as a reasoning-state backend with the chat UI and CLI as two clients of the same core.
This commit is contained in:
parent
9408fae00c
commit
89b6df16cf
11 changed files with 905 additions and 15 deletions
|
|
@ -304,12 +304,11 @@ pivot, not an incremental change.
|
|||
| Prefix | Status | Purpose |
|
||||
|---|---|---|
|
||||
| `/api/v1` | Legacy | Transcript paste ingestion → session/artifact pipeline. Not part of current workflow. |
|
||||
| `/api/v2` | Legacy | Agent-push model: repos, commits, context packs. Direct API-to-API handoff. Not part of current UI workflow. |
|
||||
| `/api/v4` | Current | Chat sessions, message sending, provider management. The primary API. |
|
||||
| `/api/v5` | Current | Checkpoint drafting. Isolated from chat API by design. |
|
||||
| `/api/v2` | Partial | Space CRUD, checkpoint read by ID, checkpoint list by space. `CommitResponse` includes `assumptions` and `artifacts` so programmatic clients (CLI, agents) can read full checkpoints via this surface. |
|
||||
| `/api/v4` | Current | Chat sessions, message sending, provider management. The primary chat API. Also the canonical checkpoint write path (`POST /chat/commit`) because it accepts the full schema including `assumptions` and `artifacts`. |
|
||||
| `/api/v5` | Current | Checkpoint drafting, review, fork, compare, lineage. Isolated from chat API by design. |
|
||||
|
||||
V1 and V2 endpoints remain registered for compatibility. They are not used by the
|
||||
current frontend. New development targets V4 and V5 exclusively.
|
||||
V1 remains registered for compatibility but is not used by the frontend or CLI.
|
||||
|
||||
The split between V4 and V5 is intentional: checkpoint operations (which involve a
|
||||
background LLM call and structured extraction) are separated from the real-time chat
|
||||
|
|
@ -317,6 +316,31 @@ path. This allows different latency budgets and error handling strategies for ea
|
|||
|
||||
---
|
||||
|
||||
## Smriti as an agent-facing backend
|
||||
|
||||
The REST API is the canonical interface to Smriti. The chat UI and the CLI are
|
||||
both clients of the same model. Nothing in the core (checkpoints, assumptions,
|
||||
decisions, artifacts, fork, compare, review) is specific to one surface.
|
||||
|
||||
Two clients ship today:
|
||||
|
||||
- **Chat UI** (`frontend/`) — the human inspection and steering surface.
|
||||
Reads and writes via V2/V4/V5 endpoints. Drives the live conversation runtime
|
||||
(`POST /api/v4/chat/send`) that injects checkpoint context into model calls.
|
||||
- **CLI** (`cli/`) — the programmatic surface for coding agents and scripts.
|
||||
Reads via V2 (`GET /commits/{id}`, `GET /repos/{id}/commits`) and V4
|
||||
(`GET /chat/spaces/{id}/head`). Writes via V4 (`POST /chat/commit`) with the
|
||||
full schema including `assumptions` and `artifacts`. Triggers review via V5
|
||||
(`POST /checkpoint/{id}/review`). Does not touch `/chat/send` — agents run
|
||||
their own reasoning in their own context, using their own LLM provider.
|
||||
Smriti is their shared memory, not their runtime.
|
||||
|
||||
Agents interact with structured state (checkpoints) via the CLI. The chat UI
|
||||
and CLI can be used simultaneously on the same project: the human steers and
|
||||
inspects, the agent reads and writes. Both see the same reasoning state.
|
||||
|
||||
---
|
||||
|
||||
## Frontend State Management
|
||||
|
||||
The frontend manages three pieces of state relevant to checkpoint isolation:
|
||||
|
|
|
|||
48
DECISIONS.md
48
DECISIONS.md
|
|
@ -181,6 +181,54 @@ the model's responses are grounded in actual content rather than just summaries
|
|||
what was discussed. Artifact content is capped at 2000 characters per artifact in
|
||||
the prompt to manage context size.
|
||||
|
||||
### Why a CLI is the first agent-facing surface, not MCP
|
||||
|
||||
Agents need a way to read and write Smriti's reasoning state from inside their
|
||||
tool loops. The two realistic transports are a CLI they can invoke via shell
|
||||
commands, and an MCP server they can call as structured tools. MCP is the better
|
||||
long-term answer: structured tool calls, native integration with hosts that
|
||||
support it, no shell indirection.
|
||||
|
||||
The CLI was chosen for V1 anyway. Reasons:
|
||||
|
||||
- Fastest path to a real end-to-end test. A CLI can be installed and wired into
|
||||
any agent that runs shell commands on the same day it ships.
|
||||
- Protocol-agnostic. Works with any host, including ones that do not speak MCP
|
||||
or treat MCP inconsistently.
|
||||
- MCP is an evolving protocol with different levels of support per host. A CLI
|
||||
is a stable contract.
|
||||
- MCP is a wrapper around the same operations the CLI already exposes. Adding
|
||||
MCP later as a second transport over the same underlying commands is a
|
||||
smaller, cleaner move than building MCP first without knowing which commands
|
||||
agents actually use.
|
||||
|
||||
MCP is the expected second transport. It gets added once the basic handoff loop
|
||||
has been proven in real use.
|
||||
|
||||
### Why the chat UI remains alongside agent-facing surfaces
|
||||
|
||||
It would be tempting to frame Smriti as "an agent backend" and deprecate the
|
||||
chat UI as legacy. That would be a mistake. The chat UI is the human inspection
|
||||
and steering surface over the same reasoning state that agents are writing to.
|
||||
When two agents have been working on a project and one of them has gone in the
|
||||
wrong direction, the human needs a way to look at what happened, fork or restore
|
||||
cleanly, and point the next agent at a better state.
|
||||
|
||||
The chat UI is not a stepping stone to an agent-only product. It is the
|
||||
permanent human-in-the-loop interface over shared reasoning state.
|
||||
|
||||
### Why agents do not touch the live chat API (`/chat/send`)
|
||||
|
||||
The V4 chat send endpoint drives the live conversation runtime: it accepts user
|
||||
messages, manages provider routing, injects checkpoint context, and stores
|
||||
turns. Agents should not invoke it. Agents write directly to structured state
|
||||
(checkpoints via `/chat/commit`) and read structured state (HEAD + commit
|
||||
fetch). They run their own reasoning in their own context, using their own LLM
|
||||
provider. Smriti is their shared memory, not their runtime.
|
||||
|
||||
This keeps the chat runtime focused on human-driven exploration, and keeps the
|
||||
agent surface narrow and composable.
|
||||
|
||||
---
|
||||
|
||||
## Open questions and deferred decisions
|
||||
|
|
|
|||
38
README.md
38
README.md
|
|
@ -135,18 +135,26 @@ It is stricter than typical chat systems. But it felt important to try.
|
|||
|
||||
---
|
||||
|
||||
## This is not just for chat
|
||||
## Smriti is a backend, not just a chat app
|
||||
|
||||
I initially built this thinking about chat. But the more I worked on it, the more it felt like this might matter more for agents.
|
||||
I initially built this thinking about chat, but the more I worked on it, the more it felt like a reasoning-state backend that happens to have a chat UI on top. Agents have the same drift / recovery / handoff problems as humans, just worse.
|
||||
|
||||
Because agents have the same problem, just worse:
|
||||
The concrete use case that drove this direction: working on a coding project and wanting to switch between different coding agents mid-project. Context reset every time. Markdown handoff files that broke down the moment reasoning branched. The strengths Smriti already had — checkpoints, restore, fork, compare, assumptions, artifacts, model interchangeability — mapped directly onto that pain.
|
||||
|
||||
- multi-step reasoning chains
|
||||
- hard to debug
|
||||
- hard to reproduce
|
||||
- no clean way to "go back"
|
||||
So Smriti now has two surfaces on the same core:
|
||||
|
||||
With checkpoints, you can persist intermediate reasoning, resume from a known state, fork execution paths, and compare outcomes across runs.
|
||||
1. **The chat UI**: how a human reads, steers, and debugs shared reasoning state. Still the primary way I inspect what is happening in a project.
|
||||
2. **A CLI**: how a coding agent reads and writes the same reasoning state from a shell tool loop.
|
||||
|
||||
One project, one Smriti Space, multiple agents reading from and writing to the same structured state. Agents don't need to know about each other. They just need to know how to read the current state and write a checkpoint when they reach an inflection point.
|
||||
|
||||
See `cli/README.md` for the agent-facing CLI. Quick taste:
|
||||
|
||||
```bash
|
||||
smriti state my-project # continuation brief
|
||||
cat checkpoint.json | smriti checkpoint create my-project
|
||||
smriti checkpoint review <id> # consistency check before continuing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -178,6 +186,16 @@ Backend: http://localhost:8000
|
|||
|
||||
There is also a mock mode if you don't want to deal with API keys.
|
||||
|
||||
### CLI (for agents and scripts)
|
||||
|
||||
```bash
|
||||
cd cli
|
||||
pip install -e .
|
||||
smriti space list
|
||||
```
|
||||
|
||||
The CLI wraps the backend API. See `cli/README.md` for the full command list and the agent handoff workflow.
|
||||
|
||||
---
|
||||
|
||||
## Docker (if you prefer that)
|
||||
|
|
@ -296,7 +314,9 @@ There is also a mock mode for trying the product without API keys.
|
|||
|
||||
## Where this is going
|
||||
|
||||
The same idea that makes reasoning recoverable in chat also applies when the reasoning happens more autonomously. If something is making decisions over multiple steps, you want to be able to inspect what it decided, go back to where it was still on track, and try a different path. That does not require a different system. It requires the same one.
|
||||
The chat UI is not going away — it is how I read, steer, and debug what is happening. But the thing underneath both the chat UI and the CLI is a shared reasoning-state layer that any client can read from and write to. Coding agents are the first real programmatic client. More transports (including MCP) can come later, but only after the basic loop is proven in real use.
|
||||
|
||||
What I care about right now: can you use two different coding agents on the same project, hand off cleanly between them via Smriti, and have the receiving agent pick up where the sender left off without re-explaining context?
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -46,14 +46,16 @@ class CommitResponse(BaseModel):
|
|||
summary: str
|
||||
objective: str
|
||||
decisions: list
|
||||
assumptions: list
|
||||
tasks: list
|
||||
open_questions: list
|
||||
entities: list
|
||||
artifacts: list
|
||||
context_blob: dict
|
||||
raw_source_text: str | None
|
||||
metadata_: dict = Field(serialization_alias="metadata")
|
||||
created_at: datetime
|
||||
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
import logging
|
||||
|
|
|
|||
103
cli/README.md
Normal file
103
cli/README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# smriti-cli
|
||||
|
||||
Command-line access to Smriti's reasoning-state backend. Built for coding agents and scripts — pipe JSON in, get readable markdown out.
|
||||
|
||||
## Install
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
cd cli
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
This installs a `smriti` command on your PATH.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the backend URL via env var (defaults to `http://localhost:8000`):
|
||||
|
||||
```bash
|
||||
export SMRITI_API_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
Or pass `--api-url` on any command.
|
||||
|
||||
## Commands
|
||||
|
||||
```
|
||||
smriti space list
|
||||
smriti space create <name> [--description "..."]
|
||||
|
||||
smriti state <space> # continuation brief
|
||||
smriti state <space> --full-artifacts # include full artifacts
|
||||
smriti state <space> --json # structured output
|
||||
|
||||
smriti checkpoint create <space> # reads JSON from stdin
|
||||
smriti checkpoint create <space> --from-json <path> # from file
|
||||
smriti checkpoint show <checkpoint-id>
|
||||
smriti checkpoint list <space>
|
||||
smriti checkpoint review <checkpoint-id>
|
||||
```
|
||||
|
||||
Every command supports `--json` for structured output.
|
||||
|
||||
## Typical agent workflow
|
||||
|
||||
Read current project state:
|
||||
|
||||
```bash
|
||||
smriti state my-project
|
||||
```
|
||||
|
||||
Write a checkpoint from a JSON object piped on stdin:
|
||||
|
||||
```bash
|
||||
cat <<'JSON' | smriti checkpoint create my-project
|
||||
{
|
||||
"message": "Decided to use Pydantic for state validation",
|
||||
"objective": "Build runtime-enforced state layer",
|
||||
"summary": "...",
|
||||
"decisions": ["Use Pydantic BaseModel for state", "extra=forbid blocks injection"],
|
||||
"assumptions": ["Latency cost is acceptable"],
|
||||
"tasks": ["Benchmark validation overhead"],
|
||||
"open_questions": ["How to handle shared state across agents"],
|
||||
"entities": ["Pydantic", "BaseModel"],
|
||||
"artifacts": [
|
||||
{"id": "a1", "type": "text", "label": "Draft implementation", "content": "..."}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
```
|
||||
|
||||
Review a specific checkpoint for consistency issues:
|
||||
|
||||
```bash
|
||||
smriti checkpoint review <checkpoint-id>
|
||||
```
|
||||
|
||||
## Checkpoint payload schema
|
||||
|
||||
Only `message` is required. Every other field defaults to empty.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `message` | string | Short title (required) |
|
||||
| `objective` | string | What you are working toward |
|
||||
| `summary` | string | Narrative of what was figured out |
|
||||
| `decisions` | string[] | Explicit choices made |
|
||||
| `assumptions` | string[] | Things taken for granted |
|
||||
| `tasks` | string[] | Concrete action items |
|
||||
| `open_questions` | string[] | Unresolved issues |
|
||||
| `entities` | string[] | Key concepts, tools, names |
|
||||
| `artifacts` | object[] | `{id, type, label, content}` entries |
|
||||
|
||||
## Space resolution
|
||||
|
||||
`<space>` arguments accept either the space name or the UUID. Names are matched exactly first, then case-insensitively. If multiple spaces match, the CLI asks you to use a UUID.
|
||||
|
||||
## Exit codes
|
||||
|
||||
- `0` success
|
||||
- `1` API error, invalid input, or backend unreachable
|
||||
- `130` interrupted (Ctrl+C)
|
||||
24
cli/pyproject.toml
Normal file
24
cli/pyproject.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "smriti-cli"
|
||||
version = "0.1.0"
|
||||
description = "Command-line interface for Smriti, the reasoning-state backend for coding agents."
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Himanshu Dongre" },
|
||||
]
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
smriti = "smriti_cli.main:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["smriti_cli*"]
|
||||
3
cli/smriti_cli/__init__.py
Normal file
3
cli/smriti_cli/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Smriti CLI — programmatic access to Smriti's reasoning-state backend."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
8
cli/smriti_cli/__main__.py
Normal file
8
cli/smriti_cli/__main__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Allow `python -m smriti_cli` as an entry point."""
|
||||
|
||||
import sys
|
||||
|
||||
from .main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
157
cli/smriti_cli/client.py
Normal file
157
cli/smriti_cli/client.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Thin HTTP wrapper over the Smriti REST API.
|
||||
|
||||
No fancy features. Each method maps to a single endpoint. The CLI layer on
|
||||
top composes these into user-facing commands and formats output.
|
||||
|
||||
All methods raise SmritiError on any non-2xx response. Callers should let
|
||||
these bubble up to main() which turns them into clean exit codes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
DEFAULT_API_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
class SmritiError(Exception):
|
||||
"""Raised for any API error or unreachable backend."""
|
||||
|
||||
def __init__(self, message: str, status: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
class SmritiClient:
|
||||
def __init__(self, base_url: str | None = None, timeout: float = 60.0):
|
||||
self.base_url = (base_url or os.environ.get("SMRITI_API_URL") or DEFAULT_API_URL).rstrip("/")
|
||||
self.timeout = timeout
|
||||
self._session = requests.Session()
|
||||
|
||||
# ── internals ──────────────────────────────────────────────────────────
|
||||
|
||||
def _request(self, method: str, path: str, *, json: Any = None, params: dict | None = None) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
try:
|
||||
resp = self._session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=json,
|
||||
params=params,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except requests.ConnectionError as e:
|
||||
raise SmritiError(
|
||||
f"Could not reach Smriti at {self.base_url}. "
|
||||
f"Is the backend running? ({e})"
|
||||
)
|
||||
except requests.Timeout:
|
||||
raise SmritiError(f"Request to {url} timed out after {self.timeout}s")
|
||||
|
||||
if not resp.ok:
|
||||
detail = None
|
||||
try:
|
||||
detail = resp.json().get("detail")
|
||||
except Exception:
|
||||
detail = resp.text[:200]
|
||||
raise SmritiError(
|
||||
f"{method} {path} failed: HTTP {resp.status_code} — {detail}",
|
||||
status=resp.status_code,
|
||||
)
|
||||
|
||||
if resp.status_code == 204 or not resp.content:
|
||||
return None
|
||||
return resp.json()
|
||||
|
||||
# ── spaces (V2) ────────────────────────────────────────────────────────
|
||||
|
||||
def list_spaces(self) -> list[dict]:
|
||||
return self._request("GET", "/api/v2/repos")
|
||||
|
||||
def get_space(self, space_id: str) -> dict:
|
||||
return self._request("GET", f"/api/v2/repos/{space_id}")
|
||||
|
||||
def create_space(self, name: str, description: str = "") -> dict:
|
||||
return self._request(
|
||||
"POST",
|
||||
"/api/v2/repos",
|
||||
json={"name": name, "description": description},
|
||||
)
|
||||
|
||||
def resolve_space(self, name_or_id: str) -> dict:
|
||||
"""Look up a space by UUID or by name. Returns the full space dict.
|
||||
|
||||
Tries UUID lookup first; falls back to scanning the space list and
|
||||
matching on name (case-sensitive exact match, then case-insensitive).
|
||||
"""
|
||||
# UUID-ish shape
|
||||
if len(name_or_id) == 36 and name_or_id.count("-") == 4:
|
||||
try:
|
||||
return self.get_space(name_or_id)
|
||||
except SmritiError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
# fall through to name lookup
|
||||
|
||||
spaces = self.list_spaces()
|
||||
exact = [s for s in spaces if s["name"] == name_or_id]
|
||||
if len(exact) == 1:
|
||||
return exact[0]
|
||||
if len(exact) > 1:
|
||||
raise SmritiError(
|
||||
f"Multiple spaces named '{name_or_id}'. Use the UUID to disambiguate."
|
||||
)
|
||||
|
||||
lower = name_or_id.lower()
|
||||
case_insensitive = [s for s in spaces if s["name"].lower() == lower]
|
||||
if len(case_insensitive) == 1:
|
||||
return case_insensitive[0]
|
||||
if len(case_insensitive) > 1:
|
||||
raise SmritiError(
|
||||
f"Multiple spaces matching '{name_or_id}' (case-insensitive). "
|
||||
f"Use the UUID to disambiguate."
|
||||
)
|
||||
|
||||
raise SmritiError(f"No space found matching '{name_or_id}'")
|
||||
|
||||
# ── checkpoints ────────────────────────────────────────────────────────
|
||||
|
||||
def get_commit(self, commit_id: str) -> dict:
|
||||
return self._request("GET", f"/api/v2/commits/{commit_id}")
|
||||
|
||||
def list_commits(self, space_id: str, branch: str | None = None) -> list[dict]:
|
||||
params = {"branch": branch} if branch else None
|
||||
return self._request("GET", f"/api/v2/repos/{space_id}/commits", params=params)
|
||||
|
||||
def get_head(self, space_id: str) -> dict:
|
||||
return self._request("GET", f"/api/v4/chat/spaces/{space_id}/head")
|
||||
|
||||
def create_chat_commit(self, payload: dict) -> dict:
|
||||
"""Create a checkpoint via the V4 commit endpoint (full schema).
|
||||
|
||||
Requires session_id. The CLI handles session creation if the caller
|
||||
does not supply one.
|
||||
"""
|
||||
return self._request("POST", "/api/v4/chat/commit", json=payload)
|
||||
|
||||
def review_checkpoint(self, commit_id: str) -> dict:
|
||||
return self._request("POST", f"/api/v5/checkpoint/{commit_id}/review")
|
||||
|
||||
# ── sessions (used internally by CLI) ──────────────────────────────────
|
||||
|
||||
def create_session(self, repo_id: str, title: str = "", provider: str = "anthropic", model: str = "claude-sonnet-4-6") -> dict:
|
||||
return self._request(
|
||||
"POST",
|
||||
"/api/v4/chat/sessions",
|
||||
json={
|
||||
"repo_id": repo_id,
|
||||
"title": title or "agent-session",
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"seed_from": "none",
|
||||
},
|
||||
)
|
||||
205
cli/smriti_cli/formatters.py
Normal file
205
cli/smriti_cli/formatters.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Markdown output formatters for the Smriti CLI.
|
||||
|
||||
The default output of every command is a readable markdown brief shaped
|
||||
so an agent or a human can paste it directly into a working context.
|
||||
Use --json on any command for structured output instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def _relative_time(iso_ts: str) -> str:
|
||||
"""Format an ISO-8601 UTC timestamp as a relative-time string."""
|
||||
try:
|
||||
then = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
return iso_ts
|
||||
now = datetime.now(timezone.utc)
|
||||
delta = now - then
|
||||
secs = int(delta.total_seconds())
|
||||
if secs < 60:
|
||||
return "just now"
|
||||
mins = secs // 60
|
||||
if mins < 60:
|
||||
return f"{mins}m ago"
|
||||
hrs = mins // 60
|
||||
if hrs < 24:
|
||||
return f"{hrs}h ago"
|
||||
days = hrs // 24
|
||||
if days < 30:
|
||||
return f"{days}d ago"
|
||||
return then.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _short_hash(commit_hash: str | None) -> str:
|
||||
return commit_hash[:7] if commit_hash else "?"
|
||||
|
||||
|
||||
def _list_section(heading: str, items: list[str]) -> str:
|
||||
if not items:
|
||||
return ""
|
||||
lines = [f"## {heading}"]
|
||||
for item in items:
|
||||
lines.append(f"- {item}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _artifact_section(artifacts: list[dict], preview_chars: int = 800, full: bool = False) -> str:
|
||||
if not artifacts:
|
||||
return ""
|
||||
lines = ["## Attached artifacts"]
|
||||
for art in artifacts:
|
||||
label = art.get("label") or "Untitled"
|
||||
content = art.get("content") or ""
|
||||
lines.append(f"### {label}")
|
||||
if full or len(content) <= preview_chars:
|
||||
lines.append(content)
|
||||
else:
|
||||
lines.append(content[:preview_chars] + "\n\n[… truncated, use --full-artifacts to see all]")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def format_state_brief(
|
||||
space: dict,
|
||||
head: dict,
|
||||
commit: dict,
|
||||
*,
|
||||
full_artifacts: bool = False,
|
||||
) -> str:
|
||||
"""A continuation-oriented markdown brief for the current project state.
|
||||
|
||||
Intended to be pasted directly into an agent's (or human's) working
|
||||
context. Sections are elided cleanly when empty.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
parts.append(f"# {space.get('name', 'Untitled space')}\n")
|
||||
if space.get("description"):
|
||||
parts.append(space["description"].rstrip() + "\n")
|
||||
|
||||
commit_hash = commit.get("commit_hash")
|
||||
created_at = commit.get("created_at") or head.get("commit_hash") or ""
|
||||
parts.append(
|
||||
f"Latest checkpoint: `{_short_hash(commit_hash)}`"
|
||||
+ (f" · {_relative_time(created_at)}" if created_at else "")
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if commit.get("objective"):
|
||||
parts.append(f"## Current objective\n{commit['objective'].rstrip()}\n")
|
||||
|
||||
if commit.get("summary"):
|
||||
parts.append(f"## Where we are\n{commit['summary'].rstrip()}\n")
|
||||
|
||||
decisions = commit.get("decisions") or []
|
||||
assumptions = commit.get("assumptions") or []
|
||||
open_questions = commit.get("open_questions") or []
|
||||
tasks = commit.get("tasks") or []
|
||||
entities = commit.get("entities") or []
|
||||
artifacts = commit.get("artifacts") or []
|
||||
|
||||
parts.append(_list_section("Decisions", decisions))
|
||||
parts.append(_list_section("Assumptions we are relying on", assumptions))
|
||||
parts.append(_list_section("Open questions", open_questions))
|
||||
parts.append(_list_section("In progress", tasks))
|
||||
parts.append(_artifact_section(artifacts, full=full_artifacts))
|
||||
|
||||
if entities:
|
||||
parts.append(f"## Key entities\n{', '.join(entities)}\n")
|
||||
|
||||
return "\n".join(p for p in parts if p).rstrip() + "\n"
|
||||
|
||||
|
||||
def format_checkpoint(commit: dict, *, full_artifacts: bool = False) -> str:
|
||||
"""Readable markdown for a single checkpoint."""
|
||||
parts: list[str] = []
|
||||
parts.append(f"# {commit.get('message', 'Untitled checkpoint')}\n")
|
||||
|
||||
commit_hash = commit.get("commit_hash")
|
||||
created_at = commit.get("created_at") or ""
|
||||
branch = commit.get("branch_name") or "main"
|
||||
meta_line = f"`{_short_hash(commit_hash)}`"
|
||||
if created_at:
|
||||
meta_line += f" · {_relative_time(created_at)}"
|
||||
meta_line += f" · branch `{branch}`"
|
||||
parts.append(meta_line + "\n")
|
||||
|
||||
if commit.get("objective"):
|
||||
parts.append(f"## Objective\n{commit['objective'].rstrip()}\n")
|
||||
|
||||
if commit.get("summary"):
|
||||
parts.append(f"## Summary\n{commit['summary'].rstrip()}\n")
|
||||
|
||||
parts.append(_list_section("Decisions", commit.get("decisions") or []))
|
||||
parts.append(_list_section("Assumptions", commit.get("assumptions") or []))
|
||||
parts.append(_list_section("Tasks", commit.get("tasks") or []))
|
||||
parts.append(_list_section("Open questions", commit.get("open_questions") or []))
|
||||
parts.append(_artifact_section(commit.get("artifacts") or [], full=full_artifacts))
|
||||
|
||||
entities = commit.get("entities") or []
|
||||
if entities:
|
||||
parts.append(f"## Entities\n{', '.join(entities)}\n")
|
||||
|
||||
return "\n".join(p for p in parts if p).rstrip() + "\n"
|
||||
|
||||
|
||||
def format_space_list(spaces: list[dict]) -> str:
|
||||
if not spaces:
|
||||
return "No spaces yet. Create one with `smriti space create <name>`.\n"
|
||||
lines = [f"{len(spaces)} space(s):", ""]
|
||||
for s in spaces:
|
||||
desc = (s.get("description") or "").strip()
|
||||
desc_line = f" {desc}" if desc else ""
|
||||
lines.append(f"- **{s['name']}** `{s['id']}`")
|
||||
if desc_line:
|
||||
lines.append(desc_line)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def format_commit_list(commits: list[dict]) -> str:
|
||||
if not commits:
|
||||
return "No checkpoints yet.\n"
|
||||
lines = [f"{len(commits)} checkpoint(s):", ""]
|
||||
for c in commits:
|
||||
created = _relative_time(c.get("created_at") or "")
|
||||
branch = c.get("branch_name") or "main"
|
||||
marker = "" if branch == "main" else f" (branch: {branch})"
|
||||
lines.append(
|
||||
f"- `{_short_hash(c.get('commit_hash'))}` {c.get('message', 'Untitled')}"
|
||||
f" · {created}{marker}"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
_REVIEW_ISSUE_LABELS = {
|
||||
"contradiction": "Possible contradiction",
|
||||
"hidden_assumption": "Hidden assumption",
|
||||
"resolved_question": "Possibly resolved",
|
||||
"unused_entity": "Possibly unused entity",
|
||||
}
|
||||
|
||||
|
||||
def format_review(result: dict) -> str:
|
||||
issues = result.get("issues") or []
|
||||
suggestions = result.get("suggestions") or []
|
||||
|
||||
if not issues and not suggestions:
|
||||
return "No issues found — reasoning looks consistent.\n"
|
||||
|
||||
parts = ["# Checkpoint review\n"]
|
||||
if issues:
|
||||
parts.append(f"Found {len(issues)} issue(s):\n")
|
||||
for i, issue in enumerate(issues, 1):
|
||||
label = _REVIEW_ISSUE_LABELS.get(issue.get("type"), issue.get("type", "Issue"))
|
||||
parts.append(f"{i}. **{label}**")
|
||||
parts.append(f" {issue.get('description', '').strip()}")
|
||||
parts.append("")
|
||||
if suggestions:
|
||||
parts.append("## Suggestions")
|
||||
for s in suggestions:
|
||||
parts.append(f"- {s}")
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts).rstrip() + "\n"
|
||||
296
cli/smriti_cli/main.py
Normal file
296
cli/smriti_cli/main.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""Smriti CLI entry point.
|
||||
|
||||
Seven commands for agent and programmatic use:
|
||||
|
||||
smriti space list
|
||||
smriti space create <name> [--description]
|
||||
smriti state <space>
|
||||
smriti checkpoint create <space> # reads JSON from stdin
|
||||
smriti checkpoint show <checkpoint-id>
|
||||
smriti checkpoint list <space>
|
||||
smriti checkpoint review <checkpoint-id>
|
||||
|
||||
Every command supports --json for structured output.
|
||||
Default output is a readable markdown brief.
|
||||
|
||||
SMRITI_API_URL env var sets the backend URL (default http://localhost:8000).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .client import SmritiClient, SmritiError
|
||||
from .formatters import (
|
||||
format_checkpoint,
|
||||
format_commit_list,
|
||||
format_review,
|
||||
format_space_list,
|
||||
format_state_brief,
|
||||
)
|
||||
|
||||
|
||||
def _print_json(data: Any) -> None:
|
||||
print(json.dumps(data, indent=2, default=str))
|
||||
|
||||
|
||||
def _fail(message: str, code: int = 1) -> None:
|
||||
print(message, file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
_USAGE_HINT = (
|
||||
"No checkpoint JSON provided. Pipe JSON on stdin, or use --from-json <path>.\n"
|
||||
"Example:\n"
|
||||
' echo \'{"message":"...","summary":"..."}\' | smriti checkpoint create my-space'
|
||||
)
|
||||
|
||||
|
||||
def _read_checkpoint_json(args: argparse.Namespace) -> dict:
|
||||
"""Read the checkpoint JSON payload from stdin or from --from-json.
|
||||
|
||||
Piping-first: if stdin is not a tty, read from stdin. Otherwise, require
|
||||
--from-json. No interactive prompt — this CLI is meant to be invoked by
|
||||
agents and scripts.
|
||||
"""
|
||||
if args.from_json:
|
||||
if args.from_json == "-":
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
with open(args.from_json, "r") as f:
|
||||
raw = f.read()
|
||||
elif not sys.stdin.isatty():
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
_fail(_USAGE_HINT)
|
||||
return {} # unreachable
|
||||
|
||||
if not raw.strip():
|
||||
_fail(_USAGE_HINT)
|
||||
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
_fail(f"error: invalid JSON input — {e}")
|
||||
return {} # unreachable
|
||||
|
||||
|
||||
# ── command handlers ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_space_list(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
spaces = client.list_spaces()
|
||||
if args.json:
|
||||
_print_json(spaces)
|
||||
else:
|
||||
print(format_space_list(spaces), end="")
|
||||
|
||||
|
||||
def cmd_space_create(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
space = client.create_space(name=args.name, description=args.description or "")
|
||||
if args.json:
|
||||
_print_json(space)
|
||||
else:
|
||||
print(f"Created space: {space['name']} `{space['id']}`")
|
||||
|
||||
|
||||
def cmd_state(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
space = client.resolve_space(args.space)
|
||||
head = client.get_head(space["id"])
|
||||
|
||||
if not head.get("commit_id"):
|
||||
# Space exists but has no checkpoints yet.
|
||||
if args.json:
|
||||
_print_json({"space": space, "head": head, "commit": None})
|
||||
return
|
||||
print(f"# {space['name']}")
|
||||
if space.get("description"):
|
||||
print(space["description"])
|
||||
print()
|
||||
print("No checkpoints yet. Create one with `smriti checkpoint create`.")
|
||||
return
|
||||
|
||||
commit = client.get_commit(head["commit_id"])
|
||||
if args.json:
|
||||
_print_json({"space": space, "head": head, "commit": commit})
|
||||
else:
|
||||
print(
|
||||
format_state_brief(space, head, commit, full_artifacts=args.full_artifacts),
|
||||
end="",
|
||||
)
|
||||
|
||||
|
||||
def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
space = client.resolve_space(args.space)
|
||||
payload = _read_checkpoint_json(args)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
_fail("Checkpoint JSON must be an object, got: " + type(payload).__name__)
|
||||
|
||||
if not payload.get("message"):
|
||||
_fail("Checkpoint JSON must include a 'message' field.")
|
||||
|
||||
# The V4 commit endpoint requires a session_id. Agents typically do not
|
||||
# have one — the CLI creates a lightweight session on demand and attaches
|
||||
# the checkpoint to it. Agents should not care about the session.
|
||||
session = client.create_session(
|
||||
repo_id=space["id"],
|
||||
title=f"cli: {payload['message'][:80]}",
|
||||
)
|
||||
|
||||
commit_payload = {
|
||||
"repo_id": space["id"],
|
||||
"session_id": session["id"],
|
||||
"message": payload["message"],
|
||||
"summary": payload.get("summary", ""),
|
||||
"objective": payload.get("objective", ""),
|
||||
"decisions": payload.get("decisions", []),
|
||||
"assumptions": payload.get("assumptions", []),
|
||||
"tasks": payload.get("tasks", []),
|
||||
"open_questions": payload.get("open_questions", []),
|
||||
"entities": payload.get("entities", []),
|
||||
"artifacts": payload.get("artifacts", []),
|
||||
}
|
||||
|
||||
commit = client.create_chat_commit(commit_payload)
|
||||
|
||||
if args.json:
|
||||
_print_json(commit)
|
||||
else:
|
||||
h = commit.get("commit_hash", "")
|
||||
print(f"Created checkpoint: `{h[:7]}` {commit.get('message', '')}")
|
||||
|
||||
|
||||
def cmd_checkpoint_show(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
commit = client.get_commit(args.checkpoint_id)
|
||||
if args.json:
|
||||
_print_json(commit)
|
||||
else:
|
||||
print(format_checkpoint(commit, full_artifacts=args.full_artifacts), end="")
|
||||
|
||||
|
||||
def cmd_checkpoint_list(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
space = client.resolve_space(args.space)
|
||||
commits = client.list_commits(space["id"], branch=args.branch)
|
||||
if args.json:
|
||||
_print_json(commits)
|
||||
else:
|
||||
print(format_commit_list(commits), end="")
|
||||
|
||||
|
||||
def cmd_checkpoint_review(client: SmritiClient, args: argparse.Namespace) -> None:
|
||||
result = client.review_checkpoint(args.checkpoint_id)
|
||||
if args.json:
|
||||
_print_json(result)
|
||||
else:
|
||||
print(format_review(result), end="")
|
||||
|
||||
|
||||
# ── argparse wiring ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="smriti",
|
||||
description="Command-line access to Smriti's reasoning-state backend.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-url",
|
||||
help="Smriti backend URL (default: $SMRITI_API_URL or http://localhost:8000)",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# space
|
||||
space_parser = subparsers.add_parser("space", help="Manage Smriti spaces (projects)")
|
||||
space_sub = space_parser.add_subparsers(dest="subcommand", required=True)
|
||||
|
||||
sp_list = space_sub.add_parser("list", help="List all spaces")
|
||||
sp_list.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
sp_list.set_defaults(func=cmd_space_list)
|
||||
|
||||
sp_create = space_sub.add_parser("create", help="Create a new space")
|
||||
sp_create.add_argument("name", help="Space name")
|
||||
sp_create.add_argument("--description", help="Optional description", default="")
|
||||
sp_create.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
sp_create.set_defaults(func=cmd_space_create)
|
||||
|
||||
# state
|
||||
state_parser = subparsers.add_parser(
|
||||
"state",
|
||||
help="Print a continuation-oriented brief of the current project state",
|
||||
)
|
||||
state_parser.add_argument("space", help="Space name or UUID")
|
||||
state_parser.add_argument(
|
||||
"--full-artifacts",
|
||||
action="store_true",
|
||||
help="Include full artifact content (default truncates previews)",
|
||||
)
|
||||
state_parser.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
state_parser.set_defaults(func=cmd_state)
|
||||
|
||||
# checkpoint
|
||||
cp_parser = subparsers.add_parser("checkpoint", help="Manage checkpoints")
|
||||
cp_sub = cp_parser.add_subparsers(dest="subcommand", required=True)
|
||||
|
||||
cp_create = cp_sub.add_parser(
|
||||
"create",
|
||||
help="Create a checkpoint from JSON on stdin or --from-json <path>",
|
||||
)
|
||||
cp_create.add_argument("space", help="Space name or UUID")
|
||||
cp_create.add_argument(
|
||||
"--from-json",
|
||||
help="Path to a JSON file with the checkpoint payload (use '-' for stdin)",
|
||||
)
|
||||
cp_create.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
cp_create.set_defaults(func=cmd_checkpoint_create)
|
||||
|
||||
cp_show = cp_sub.add_parser("show", help="Print a specific checkpoint as markdown")
|
||||
cp_show.add_argument("checkpoint_id", help="Checkpoint UUID")
|
||||
cp_show.add_argument(
|
||||
"--full-artifacts",
|
||||
action="store_true",
|
||||
help="Include full artifact content",
|
||||
)
|
||||
cp_show.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
cp_show.set_defaults(func=cmd_checkpoint_show)
|
||||
|
||||
cp_list = cp_sub.add_parser("list", help="List checkpoints in a space")
|
||||
cp_list.add_argument("space", help="Space name or UUID")
|
||||
cp_list.add_argument("--branch", help="Filter by branch name")
|
||||
cp_list.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
cp_list.set_defaults(func=cmd_checkpoint_list)
|
||||
|
||||
cp_review = cp_sub.add_parser("review", help="Run consistency review on a checkpoint")
|
||||
cp_review.add_argument("checkpoint_id", help="Checkpoint UUID")
|
||||
cp_review.add_argument("--json", action="store_true", help="Output structured JSON")
|
||||
cp_review.set_defaults(func=cmd_checkpoint_review)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
client = SmritiClient(base_url=args.api_url)
|
||||
|
||||
try:
|
||||
args.func(client, args)
|
||||
except SmritiError as e:
|
||||
_fail(f"error: {e}")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
_fail(f"error: invalid JSON input — {e}")
|
||||
except FileNotFoundError as e:
|
||||
_fail(f"error: file not found — {e}")
|
||||
except KeyboardInterrupt:
|
||||
return 130
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Reference in a new issue