standupguy 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calas Automations Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: standupguy
3
+ Version: 0.1.0
4
+ Summary: Local-first standup notes from git ground truth.
5
+ Author: Calas Automations Inc.
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/calasdan-sketch/standupguy
8
+ Project-URL: Issues, https://github.com/calasdan-sketch/standupguy/issues
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # StandupGuy (`standupguy`)
15
+
16
+ Kill the tracker trap without leaking data.
17
+
18
+ Engineers lose up to 2/5 of the week to reporting busywork — duplicating
19
+ updates across Jira, Slack, Confluence, and slide decks. AI note-takers are
20
+ cloud SaaS with data-leak concerns. `standupguy` is the local-first
21
+ alternative: it reads your **git history** (ground truth) and drafts your
22
+ standup notes, weekly summary, and sprint-review bullets as Markdown.
23
+ **Your history never leaves your laptop** — except to the model endpoint
24
+ *you* configure (Ollama on localhost by default).
25
+
26
+ ## Install
27
+
28
+ Zero dependencies — Python 3.10+ stdlib only.
29
+
30
+ ```bash
31
+ git clone <repo> && cd standupguy
32
+ # no-install alias (run from the repo root):
33
+ alias standupguy="python3 -m standupguy"
34
+ # or: pip install . # provides the `standupguy` console script
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ standupguy # yesterday's digest (deterministic, offline)
41
+ standupguy --week # last 7 days
42
+ standupguy --sprint # sprint-review bullets
43
+ standupguy --since 2026-10-01 # custom range
44
+ standupguy --draft # LLM prose: Yesterday / Today / Blockers
45
+ standupguy --format json # machine-readable digest
46
+ standupguy --author "teammate@co" # filter (default: your git user)
47
+ standupguy --all # everyone
48
+ standupguy --push-slack # POST to AUTOSTANDUP_SLACK_WEBHOOK
49
+ standupguy mcp # MCP server on stdio
50
+ standupguy serve # HTTP bridge for Activepieces / workflow tools
51
+ ```
52
+
53
+ ## Activepieces
54
+
55
+ `standupguy serve` exposes the digest over HTTP so Activepieces' HTTP
56
+ Request piece can pull it on a schedule:
57
+
58
+ ```bash
59
+ standupguy serve --root /path/to/repo # localhost:8765
60
+ ```
61
+
62
+ - `GET /digest?since=yesterday&author=&format=json|markdown`
63
+ - `GET /week?author=&format=json|markdown`
64
+ - `GET /health`
65
+
66
+ [`workflows/activepieces-standup-digest.json`](workflows/activepieces-standup-digest.json)
67
+ is a documented flow definition (it's a human-readable spec, not a native
68
+ export — build it in Activepieces by adding the steps in order; the file
69
+ carries the exact config for each step and setup notes at the top):
70
+ weekday 9am Schedule trigger → HTTP Request → Branch (skip quiet days) →
71
+ Slack. Connect your Slack connection, set the channel, done. The same
72
+ `serve` endpoints work from n8n's HTTP Request node too — nothing in the
73
+ bridge is platform-specific.
74
+
75
+ Example output:
76
+
77
+ ```markdown
78
+ # Standup — myrepo
79
+ _since 2026-10-14_
80
+
81
+ ## Shipped
82
+ ### Features
83
+ - add login flow (1 files) `6785c87`
84
+ ### Fixes
85
+ - null pointer on logout (1 files) `8ce2731`
86
+
87
+ ## In progress (uncommitted)
88
+ - `wip.py`
89
+
90
+ ## Blockers
91
+ _none reported — fill in yours_
92
+ ```
93
+
94
+ ## How it works
95
+
96
+ - **Digest mode (default):** pure Python, fully offline. Commits grouped by
97
+ conventional-commit prefix (feat/fix/docs/refactor/test/chore); merge
98
+ commits surface as shipped PRs; `git status` feeds the In-progress section.
99
+ - **Draft mode (`--draft`):** sends the digest to your local model via an
100
+ OpenAI-compatible endpoint and gets back Yesterday/Today/Blockers prose.
101
+ Configure with env vars (Ollama defaults shown):
102
+
103
+ ```bash
104
+ export AUTOSTANDUP_BASE_URL="http://localhost:11434/v1"
105
+ export AUTOSTANDUP_MODEL="llama3.1"
106
+ # freellmapi works too — it's OpenAI-compatible:
107
+ export AUTOSTANDUP_BASE_URL="https://api.freellmapi.com/v1"
108
+ export AUTOSTANDUP_API_KEY="<your key>"
109
+ ```
110
+
111
+ - **MCP server:** exposes `what_did_i_ship {since?, author?, root?}` so your
112
+ coding agent can answer "what did I ship this week" from local git.
113
+ - **Push:** `--push-slack` POSTs the Markdown to a Slack incoming webhook
114
+ (URL from the flag or `AUTOSTANDUP_SLACK_WEBHOOK`). Commit-derived text
115
+ is sanitized before posting: control characters stripped, lines
116
+ truncated at 500 chars, and `@channel`/`@here`/`@everyone` neutralized
117
+ to `[at]channel`-style text — so a hostile commit message can never ping
118
+ the channel or inject payloads. Jira/Confluence push
119
+ is intentionally not built in — paste the Markdown, keep your tokens
120
+ out of another integration.
121
+
122
+ ## Date handling
123
+
124
+ Bare dates and words are normalized to explicit midnight (`2026-10-14` →
125
+ `2026-10-14 00:00`, `yesterday` → yesterday 00:00) because git's date parser
126
+ treats them as *noon* — silently dropping morning commits.
127
+
128
+ ## Tests
129
+
130
+ ```bash
131
+ python3 -m unittest discover -s tests # 36 tests, no network, no model
132
+ ```
133
+
134
+ ## License
135
+
136
+ TBD
@@ -0,0 +1,123 @@
1
+ # StandupGuy (`standupguy`)
2
+
3
+ Kill the tracker trap without leaking data.
4
+
5
+ Engineers lose up to 2/5 of the week to reporting busywork — duplicating
6
+ updates across Jira, Slack, Confluence, and slide decks. AI note-takers are
7
+ cloud SaaS with data-leak concerns. `standupguy` is the local-first
8
+ alternative: it reads your **git history** (ground truth) and drafts your
9
+ standup notes, weekly summary, and sprint-review bullets as Markdown.
10
+ **Your history never leaves your laptop** — except to the model endpoint
11
+ *you* configure (Ollama on localhost by default).
12
+
13
+ ## Install
14
+
15
+ Zero dependencies — Python 3.10+ stdlib only.
16
+
17
+ ```bash
18
+ git clone <repo> && cd standupguy
19
+ # no-install alias (run from the repo root):
20
+ alias standupguy="python3 -m standupguy"
21
+ # or: pip install . # provides the `standupguy` console script
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```bash
27
+ standupguy # yesterday's digest (deterministic, offline)
28
+ standupguy --week # last 7 days
29
+ standupguy --sprint # sprint-review bullets
30
+ standupguy --since 2026-10-01 # custom range
31
+ standupguy --draft # LLM prose: Yesterday / Today / Blockers
32
+ standupguy --format json # machine-readable digest
33
+ standupguy --author "teammate@co" # filter (default: your git user)
34
+ standupguy --all # everyone
35
+ standupguy --push-slack # POST to AUTOSTANDUP_SLACK_WEBHOOK
36
+ standupguy mcp # MCP server on stdio
37
+ standupguy serve # HTTP bridge for Activepieces / workflow tools
38
+ ```
39
+
40
+ ## Activepieces
41
+
42
+ `standupguy serve` exposes the digest over HTTP so Activepieces' HTTP
43
+ Request piece can pull it on a schedule:
44
+
45
+ ```bash
46
+ standupguy serve --root /path/to/repo # localhost:8765
47
+ ```
48
+
49
+ - `GET /digest?since=yesterday&author=&format=json|markdown`
50
+ - `GET /week?author=&format=json|markdown`
51
+ - `GET /health`
52
+
53
+ [`workflows/activepieces-standup-digest.json`](workflows/activepieces-standup-digest.json)
54
+ is a documented flow definition (it's a human-readable spec, not a native
55
+ export — build it in Activepieces by adding the steps in order; the file
56
+ carries the exact config for each step and setup notes at the top):
57
+ weekday 9am Schedule trigger → HTTP Request → Branch (skip quiet days) →
58
+ Slack. Connect your Slack connection, set the channel, done. The same
59
+ `serve` endpoints work from n8n's HTTP Request node too — nothing in the
60
+ bridge is platform-specific.
61
+
62
+ Example output:
63
+
64
+ ```markdown
65
+ # Standup — myrepo
66
+ _since 2026-10-14_
67
+
68
+ ## Shipped
69
+ ### Features
70
+ - add login flow (1 files) `6785c87`
71
+ ### Fixes
72
+ - null pointer on logout (1 files) `8ce2731`
73
+
74
+ ## In progress (uncommitted)
75
+ - `wip.py`
76
+
77
+ ## Blockers
78
+ _none reported — fill in yours_
79
+ ```
80
+
81
+ ## How it works
82
+
83
+ - **Digest mode (default):** pure Python, fully offline. Commits grouped by
84
+ conventional-commit prefix (feat/fix/docs/refactor/test/chore); merge
85
+ commits surface as shipped PRs; `git status` feeds the In-progress section.
86
+ - **Draft mode (`--draft`):** sends the digest to your local model via an
87
+ OpenAI-compatible endpoint and gets back Yesterday/Today/Blockers prose.
88
+ Configure with env vars (Ollama defaults shown):
89
+
90
+ ```bash
91
+ export AUTOSTANDUP_BASE_URL="http://localhost:11434/v1"
92
+ export AUTOSTANDUP_MODEL="llama3.1"
93
+ # freellmapi works too — it's OpenAI-compatible:
94
+ export AUTOSTANDUP_BASE_URL="https://api.freellmapi.com/v1"
95
+ export AUTOSTANDUP_API_KEY="<your key>"
96
+ ```
97
+
98
+ - **MCP server:** exposes `what_did_i_ship {since?, author?, root?}` so your
99
+ coding agent can answer "what did I ship this week" from local git.
100
+ - **Push:** `--push-slack` POSTs the Markdown to a Slack incoming webhook
101
+ (URL from the flag or `AUTOSTANDUP_SLACK_WEBHOOK`). Commit-derived text
102
+ is sanitized before posting: control characters stripped, lines
103
+ truncated at 500 chars, and `@channel`/`@here`/`@everyone` neutralized
104
+ to `[at]channel`-style text — so a hostile commit message can never ping
105
+ the channel or inject payloads. Jira/Confluence push
106
+ is intentionally not built in — paste the Markdown, keep your tokens
107
+ out of another integration.
108
+
109
+ ## Date handling
110
+
111
+ Bare dates and words are normalized to explicit midnight (`2026-10-14` →
112
+ `2026-10-14 00:00`, `yesterday` → yesterday 00:00) because git's date parser
113
+ treats them as *noon* — silently dropping morning commits.
114
+
115
+ ## Tests
116
+
117
+ ```bash
118
+ python3 -m unittest discover -s tests # 36 tests, no network, no model
119
+ ```
120
+
121
+ ## License
122
+
123
+ TBD
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+ [project]
5
+ name = "standupguy"
6
+ version = "0.1.0"
7
+ description = "Local-first standup notes from git ground truth."
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ authors = [{name = "Calas Automations Inc."}]
11
+ license = {text = "MIT"}
12
+
13
+ [project.urls]
14
+ Homepage = "https://github.com/calasdan-sketch/standupguy"
15
+ Issues = "https://github.com/calasdan-sketch/standupguy/issues"
16
+
17
+ [project.scripts]
18
+ standupguy = "standupguy.cli:main"
19
+
20
+ [tool.setuptools]
21
+ packages = ["standupguy"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,134 @@
1
+ """StandupGuy: kill the tracker trap without leaking data.
2
+
3
+ Reads local git history and drafts your standup notes, weekly summary,
4
+ and sprint-review bullets as Markdown. Deterministic digest works fully
5
+ offline; --draft uses YOUR local model endpoint (Ollama/freellmapi).
6
+ Your history never touches a cloud SaaS.
7
+
8
+ standupguy # yesterday's digest
9
+ standupguy --week # last 7 days
10
+ standupguy --draft # LLM prose: Yesterday / Today / Blockers
11
+ standupguy --sprint # sprint-review bullets
12
+ standupguy mcp # MCP server: ask "what did I ship this week"
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+
22
+ from . import digest as digest_mod
23
+ from . import draft as draft_mod
24
+ from . import gitlog as gitlog_mod
25
+ from . import mcp as mcp_mod
26
+ from . import push as push_mod
27
+ from . import serve as serve_mod
28
+
29
+ VERSION = "0.1.0"
30
+
31
+
32
+ def _resolve_author(args, root: str) -> str | None:
33
+ if args.all:
34
+ return None
35
+ if args.author:
36
+ return args.author
37
+ return gitlog_mod.default_author(root) or None
38
+
39
+
40
+ def cmd_digest(args) -> int:
41
+ root = args.root
42
+ author = _resolve_author(args, root)
43
+ if args.week or args.sprint:
44
+ md = digest_mod.weekly_markdown(root, author)
45
+ label = "week"
46
+ else:
47
+ since = args.since or "yesterday"
48
+ label = f"since {since}"
49
+ md = digest_mod.digest_markdown(root, since, label, author)
50
+ if args.draft:
51
+ try:
52
+ md = draft_mod.draft(
53
+ md, base_url=args.base_url, model=args.model) + "\n"
54
+ except draft_mod.DraftError as e:
55
+ print(f"error: {e}", file=sys.stderr)
56
+ return 2
57
+ if args.format == "json":
58
+ commits = gitlog_mod.commits_since(
59
+ root, "7 days ago" if (args.week or args.sprint) else
60
+ (args.since or "yesterday"), author)
61
+ print(json.dumps({
62
+ "repo": gitlog_mod.repo_name(root), "label": label,
63
+ "in_progress": gitlog_mod.uncommitted(root),
64
+ "commits": [{"sha": c.sha, "author": c.author, "date": c.date,
65
+ "subject": c.subject, "files": c.files,
66
+ "pr": c.pr_number} for c in commits],
67
+ }, indent=2))
68
+ return 0
69
+ if args.push_slack:
70
+ webhook = args.push_slack if args.push_slack is not True else \
71
+ os.environ.get("AUTOSTANDUP_SLACK_WEBHOOK", "")
72
+ if not webhook:
73
+ print("error: no webhook — pass --push-slack <url> or set "
74
+ "AUTOSTANDUP_SLACK_WEBHOOK", file=sys.stderr)
75
+ return 2
76
+ try:
77
+ push_mod.push_slack(webhook, md)
78
+ print("pushed to Slack ✓")
79
+ except push_mod.PushError as e:
80
+ print(f"error: {e}", file=sys.stderr)
81
+ return 2
82
+ return 0
83
+ sys.stdout.write(md)
84
+ return 0
85
+
86
+
87
+ def build_parser() -> argparse.ArgumentParser:
88
+ p = argparse.ArgumentParser(prog="standupguy", description=__doc__)
89
+ p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
90
+ p.add_argument("--root", default=os.getcwd(), help="Repo path.")
91
+ p.add_argument("--author", default=None, help="Filter by author.")
92
+ p.add_argument("--all", action="store_true",
93
+ help="Include all authors (default: your git user).")
94
+ p.add_argument("--since", default=None,
95
+ help="git date: 'yesterday' (default), '2026-10-01', …")
96
+ p.add_argument("--week", action="store_true", help="Last 7 days.")
97
+ p.add_argument("--sprint", action="store_true",
98
+ help="Sprint-review bullets (weekly + highlights).")
99
+ p.add_argument("--draft", action="store_true",
100
+ help="Draft Yesterday/Today/Blockers prose via local model.")
101
+ p.add_argument("--format", choices=["markdown", "json"],
102
+ default="markdown")
103
+ p.add_argument("--push-slack", nargs="?", const=True, default=None,
104
+ metavar="WEBHOOK_URL",
105
+ help="POST the notes to a Slack webhook "
106
+ "(or AUTOSTANDUP_SLACK_WEBHOOK).")
107
+ p.add_argument("--base-url", default=None)
108
+ p.add_argument("--model", default=None)
109
+ p.add_argument("cmd", nargs="?", choices=["mcp", "serve"],
110
+ help="Run the MCP server (mcp) or HTTP bridge (serve) "
111
+ "for n8n / workflow tools.")
112
+ p.add_argument("--port", type=int, default=8765,
113
+ help="Port for `serve` (default 8765).")
114
+ p.add_argument("--host", default="127.0.0.1",
115
+ help="Bind host for `serve` (default 127.0.0.1).")
116
+ return p
117
+
118
+
119
+ def main(argv=None) -> int:
120
+ args = build_parser().parse_args(argv)
121
+ if args.cmd == "mcp":
122
+ return mcp_mod.serve(args.root)
123
+ if args.cmd == "serve":
124
+ return serve_mod.serve(args.root, args.host, args.port)
125
+ try:
126
+ gitlog_mod._git(args.root, "rev-parse", "--git-dir")
127
+ except RuntimeError:
128
+ print("error: not a git repo", file=sys.stderr)
129
+ return 2
130
+ return cmd_digest(args)
131
+
132
+
133
+ if __name__ == "__main__":
134
+ sys.exit(main())
@@ -0,0 +1,95 @@
1
+ """Digest: turn commits into grouped Markdown. Deterministic, offline.
2
+
3
+ Groups by conventional-commit prefix (feat/fix/docs/refactor/test/chore),
4
+ falls back to an 'other' bucket. Merge commits surface as shipped PRs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import OrderedDict
11
+
12
+ from . import gitlog as gitlog_mod
13
+
14
+ _PREFIX_RE = re.compile(r"^(\w+)(?:\(.+\))?[:!]\s*(.+)$", re.IGNORECASE)
15
+
16
+ GROUP_ORDER = ["feat", "fix", "refactor", "docs", "test", "chore", "other"]
17
+ GROUP_TITLES = {
18
+ "feat": "Features", "fix": "Fixes", "refactor": "Refactors",
19
+ "docs": "Docs", "test": "Tests", "chore": "Chores", "other": "Other",
20
+ }
21
+
22
+
23
+ def classify(subject: str) -> tuple[str, str]:
24
+ """(group, cleaned subject)."""
25
+ m = _PREFIX_RE.match(subject.strip())
26
+ if m and m.group(1).lower() in GROUP_TITLES:
27
+ return m.group(1).lower(), m.group(2).strip()
28
+ return "other", subject.strip()
29
+
30
+
31
+ def group_commits(commits: list[gitlog_mod.Commit]
32
+ ) -> "OrderedDict[str, list[gitlog_mod.Commit]]":
33
+ groups: OrderedDict[str, list] = OrderedDict(
34
+ (g, []) for g in GROUP_ORDER)
35
+ for c in commits:
36
+ g, _ = classify(c.subject)
37
+ groups[g].append(c)
38
+ return groups
39
+
40
+
41
+ def _commit_line(c: gitlog_mod.Commit) -> str:
42
+ _, clean = classify(c.subject)
43
+ files = f" ({len(c.files)} files)" if c.files else ""
44
+ pr = f" [PR #{c.pr_number}]" if c.pr_number else ""
45
+ return f"- {clean}{pr}{files} `{c.sha[:7]}`"
46
+
47
+
48
+ def digest_markdown(root: str, since: str, label: str,
49
+ author: str | None = None) -> str:
50
+ commits = gitlog_mod.commits_since(root, since, author)
51
+ in_progress = gitlog_mod.uncommitted(root)
52
+ repo = gitlog_mod.repo_name(root)
53
+ lines = [f"# Standup — {repo}", f"_{label}_", ""]
54
+ if not commits and not in_progress:
55
+ lines.append("No commits or uncommitted changes in range. "
56
+ "Touch grass. 🌱")
57
+ return "\n".join(lines) + "\n"
58
+ if commits:
59
+ lines.append("## Shipped")
60
+ for group, items in group_commits(commits).items():
61
+ if not items:
62
+ continue
63
+ lines.append(f"### {GROUP_TITLES[group]}")
64
+ lines.extend(_commit_line(c) for c in items)
65
+ lines.append("")
66
+ if in_progress:
67
+ lines.append("## In progress (uncommitted)")
68
+ lines.extend(f"- `{p}`" for p in sorted(in_progress)[:20])
69
+ if len(in_progress) > 20:
70
+ lines.append(f"- …and {len(in_progress) - 20} more")
71
+ lines.append("")
72
+ lines.append("## Blockers")
73
+ lines.append("_none reported — fill in yours_")
74
+ lines.append("")
75
+ return "\n".join(lines)
76
+
77
+
78
+ def weekly_markdown(root: str, author: str | None = None) -> str:
79
+ commits = gitlog_mod.commits_since(root, "7 days ago", author)
80
+ repo = gitlog_mod.repo_name(root)
81
+ by_day: OrderedDict[str, list] = OrderedDict()
82
+ for c in commits:
83
+ by_day.setdefault(c.date, []).append(c)
84
+ lines = [f"# Weekly summary — {repo}",
85
+ f"_{len(commits)} commits in the last 7 days_", ""]
86
+ for day in sorted(by_day):
87
+ lines.append(f"## {day}")
88
+ for group, items in group_commits(by_day[day]).items():
89
+ if items:
90
+ lines.append(f"### {GROUP_TITLES[group]}")
91
+ lines.extend(_commit_line(c) for c in items)
92
+ lines.append("")
93
+ lines.append("## Highlights for sprint review")
94
+ lines.append("_pick 3–5 bullets from above_")
95
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,100 @@
1
+ """Draft: turn a digest into Yesterday/Today/Blockers prose via a local model.
2
+
3
+ Local-first: the default endpoint is Ollama on localhost. Point
4
+ AUTOSTANDUP_BASE_URL at freellmapi or any OpenAI-compatible endpoint.
5
+ Your git history never leaves the machine except to the endpoint YOU
6
+ configured. No cloud SaaS, no data-leak ceremony.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import urllib.request
14
+ import urllib.error
15
+
16
+
17
+ class DraftError(RuntimeError):
18
+ pass
19
+
20
+
21
+ # Largest digest (characters) sent to the model for --draft. A full-repo
22
+ # digest can be hundreds of KB (the Round 2 panel measured 569KB/142k
23
+ # tokens on one repo), so the digest is truncated for the MODEL CALL only;
24
+ # the digest printed to the terminal is untouched.
25
+ MAX_DIGEST_CHARS = 100_000
26
+
27
+ TRUNCATION_NOTE = (
28
+ "\n\n[Note: this digest was truncated to 100,000 characters for the "
29
+ "model call. The full digest is still available above.]")
30
+
31
+
32
+ DRAFT_TEMPLATE = """You are writing a developer's daily standup notes. Be terse.
33
+ Use exactly these three sections:
34
+
35
+ Yesterday:
36
+ - 2-5 bullets of what was accomplished, in plain past tense.
37
+
38
+ Today:
39
+ - 2-4 bullets of what is in progress or planned next, inferred from the
40
+ in-progress files and the trajectory of yesterday's work.
41
+
42
+ Blockers:
43
+ - "None reported." unless the digest clearly shows something stuck.
44
+
45
+ Rules: no fluff, no emojis, no inventing work that isn't in the digest.
46
+ If the digest is thin, say so in one line and stop.
47
+
48
+ Digest:
49
+ ---
50
+ {digest}
51
+ ---
52
+ """
53
+
54
+
55
+ def _cfg(base_url=None, api_key=None, model=None):
56
+ return {
57
+ "base_url": (base_url or os.environ.get("AUTOSTANDUP_BASE_URL")
58
+ or "http://localhost:11434/v1").rstrip("/"),
59
+ "api_key": api_key or os.environ.get("AUTOSTANDUP_API_KEY") or "ollama",
60
+ "model": model or os.environ.get("AUTOSTANDUP_MODEL") or "llama3.1",
61
+ }
62
+
63
+
64
+ def _cap_digest(digest_md: str) -> str:
65
+ """Truncate the digest for the model call; the terminal copy is full.
66
+
67
+ Returns the digest unchanged when it fits; otherwise keeps the first
68
+ MAX_DIGEST_CHARS characters and appends a note saying so. The note is
69
+ plain text without {} so DRAFT_TEMPLATE.format() stays safe.
70
+ """
71
+ if len(digest_md) <= MAX_DIGEST_CHARS:
72
+ return digest_md
73
+ return digest_md[:MAX_DIGEST_CHARS] + TRUNCATION_NOTE
74
+
75
+
76
+ def draft(digest_md: str, *, base_url=None, api_key=None, model=None,
77
+ timeout: int = 120) -> str:
78
+ digest_md = _cap_digest(digest_md)
79
+ cfg = _cfg(base_url, api_key, model)
80
+ body = json.dumps({
81
+ "model": cfg["model"],
82
+ "messages": [{"role": "user",
83
+ "content": DRAFT_TEMPLATE.format(digest=digest_md)}],
84
+ "temperature": 0.3,
85
+ }).encode()
86
+ req = urllib.request.Request(
87
+ cfg["base_url"] + "/chat/completions", data=body,
88
+ headers={"Content-Type": "application/json",
89
+ "Authorization": f"Bearer {cfg['api_key']}"})
90
+ try:
91
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
92
+ data = json.loads(resp.read().decode())
93
+ except urllib.error.URLError as e:
94
+ raise DraftError(
95
+ f"cannot reach model at {cfg['base_url']} "
96
+ f"(set AUTOSTANDUP_BASE_URL): {e.reason}")
97
+ try:
98
+ return data["choices"][0]["message"]["content"].strip()
99
+ except (KeyError, IndexError, TypeError) as e:
100
+ raise DraftError(f"unexpected response shape: {e}")