walla-cli 0.1.13__tar.gz → 0.1.14__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.
Files changed (128) hide show
  1. {walla_cli-0.1.13 → walla_cli-0.1.14}/AGENTS.md +5 -2
  2. {walla_cli-0.1.13 → walla_cli-0.1.14}/PKG-INFO +16 -2
  3. {walla_cli-0.1.13 → walla_cli-0.1.14}/README.md +15 -1
  4. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/data_engineering_standards.md +3 -1
  5. walla_cli-0.1.14/incoming/teach/README.md +3 -0
  6. {walla_cli-0.1.13 → walla_cli-0.1.14}/pyproject.toml +1 -1
  7. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_cli_json.py +5 -1
  8. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_core.py +2 -1
  9. walla_cli-0.1.14/tests/test_teach.py +73 -0
  10. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/__init__.py +1 -1
  11. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/cmd_ops.py +6 -1
  12. walla_cli-0.1.14/walla/cli/cmd_teach.py +53 -0
  13. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/main.py +10 -1
  14. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/core/instruct.py +13 -0
  15. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/core/path.py +10 -0
  16. walla_cli-0.1.14/walla/core/teach.py +138 -0
  17. walla_cli-0.1.14/walla/core/teach_pr.py +199 -0
  18. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/mcp/server.py +22 -0
  19. {walla_cli-0.1.13 → walla_cli-0.1.14}/.cursor/rules/quality.mdc +0 -0
  20. {walla_cli-0.1.13 → walla_cli-0.1.14}/.github/workflows/ci.yml +0 -0
  21. {walla_cli-0.1.13 → walla_cli-0.1.14}/.gitignore +0 -0
  22. {walla_cli-0.1.13 → walla_cli-0.1.14}/LICENSE +0 -0
  23. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/WIRE.md +0 -0
  24. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/assets/walla-demo-poster.jpg +0 -0
  25. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/assets/walla-demo-preview.gif +0 -0
  26. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/assets/walla-demo.mp4 +0 -0
  27. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/assets/walla-github-banner.jpg +0 -0
  28. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/code_quality.md +0 -0
  29. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/mcp.md +0 -0
  30. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/objective_function.md +0 -0
  31. {walla_cli-0.1.13 → walla_cli-0.1.14}/docs/rate_limit_analysis.md +0 -0
  32. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/categories.json +0 -0
  33. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/edit_item_request.json +0 -0
  34. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/item_detail.json +0 -0
  35. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/offer_buyer_request.json +0 -0
  36. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/search_bicicleta.json +0 -0
  37. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/sell_item_request.json +0 -0
  38. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/sell_suggest_draft.json +0 -0
  39. {walla_cli-0.1.13 → walla_cli-0.1.14}/fixtures/wire_contracts.json +0 -0
  40. {walla_cli-0.1.13 → walla_cli-0.1.14}/scripts/check_code_quality.py +0 -0
  41. {walla_cli-0.1.13 → walla_cli-0.1.14}/scripts/dogfood_run.py +0 -0
  42. {walla_cli-0.1.13 → walla_cli-0.1.14}/scripts/probe_rate.py +0 -0
  43. {walla_cli-0.1.13 → walla_cli-0.1.14}/skills/walla/SKILL.md +0 -0
  44. {walla_cli-0.1.13 → walla_cli-0.1.14}/skills/walla-buy/SKILL.md +0 -0
  45. {walla_cli-0.1.13 → walla_cli-0.1.14}/skills/walla-sell/SKILL.md +0 -0
  46. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_account.py +0 -0
  47. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_account_more.py +0 -0
  48. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_catch.py +0 -0
  49. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_categories_find.py +0 -0
  50. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_chat_open.py +0 -0
  51. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_desk.py +0 -0
  52. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_edges.py +0 -0
  53. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_edit.py +0 -0
  54. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_export.py +0 -0
  55. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_http_more.py +0 -0
  56. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_live.py +0 -0
  57. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_negotiate.py +0 -0
  58. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_parsers.py +0 -0
  59. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_polite.py +0 -0
  60. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_regressions.py +0 -0
  61. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_search_api.py +0 -0
  62. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_sell.py +0 -0
  63. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_session_store.py +0 -0
  64. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_shortlist.py +0 -0
  65. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_suggest_watch.py +0 -0
  66. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_verdict.py +0 -0
  67. {walla_cli-0.1.13 → walla_cli-0.1.14}/tests/test_wire_contracts.py +0 -0
  68. {walla_cli-0.1.13 → walla_cli-0.1.14}/uv.lock +0 -0
  69. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/__init__.py +0 -0
  70. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/actions.py +0 -0
  71. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/chat.py +0 -0
  72. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/cookies.py +0 -0
  73. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/desk.py +0 -0
  74. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/desk_watch.py +0 -0
  75. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/inbox.py +0 -0
  76. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/login.py +0 -0
  77. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/offer_wire.py +0 -0
  78. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/prompt.py +0 -0
  79. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell.py +0 -0
  80. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell_edit.py +0 -0
  81. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell_edit_wire.py +0 -0
  82. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell_photos.py +0 -0
  83. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell_steps.py +0 -0
  84. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell_suggest.py +0 -0
  85. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell_upload.py +0 -0
  86. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/sell_wire.py +0 -0
  87. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/account/session_store.py +0 -0
  88. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/__init__.py +0 -0
  89. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/banner.py +0 -0
  90. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/catch.py +0 -0
  91. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/cmd_account.py +0 -0
  92. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/cmd_desk.py +0 -0
  93. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/cmd_edit.py +0 -0
  94. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/cmd_look.py +0 -0
  95. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/cmd_sell.py +0 -0
  96. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/cli/console.py +0 -0
  97. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/core/__init__.py +0 -0
  98. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/core/envelope.py +0 -0
  99. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/core/errors.py +0 -0
  100. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/core/exceptions.py +0 -0
  101. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/core/human_fix.py +0 -0
  102. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/http/__init__.py +0 -0
  103. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/http/client.py +0 -0
  104. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/http/headers.py +0 -0
  105. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/http/polite.py +0 -0
  106. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/http/sign.py +0 -0
  107. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/__init__.py +0 -0
  108. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/converge.py +0 -0
  109. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/export.py +0 -0
  110. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/export_pdf.py +0 -0
  111. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/negotiate.py +0 -0
  112. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/profile_store.py +0 -0
  113. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/pursuits.py +0 -0
  114. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/shortlist.py +0 -0
  115. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/stack.py +0 -0
  116. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/verdict.py +0 -0
  117. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/hunter/watches.py +0 -0
  118. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/mcp/__init__.py +0 -0
  119. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/mcp/_entry.py +0 -0
  120. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/models/__init__.py +0 -0
  121. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/models/account.py +0 -0
  122. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/models/listing.py +0 -0
  123. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/models/profile.py +0 -0
  124. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/search/__init__.py +0 -0
  125. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/search/api.py +0 -0
  126. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/search/category_find.py +0 -0
  127. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/search/parse.py +0 -0
  128. {walla_cli-0.1.13 → walla_cli-0.1.14}/walla/search/pick_category.py +0 -0
@@ -67,5 +67,8 @@ Ranks: models 10 → exceptions 15 → http 20 → core 30 → search/account/hu
67
67
  - **Don't** create listings without `Accept: application/vnd.upload-v2+json`
68
68
  (HTTP 405). Use `walla sell` / `fixtures/sell_item_request.json`.
69
69
  - **Don't** require `.env` credentials; login is cookie paste.
70
- - **Don't** leave a sticky `must_match` from a prior hunt; clear with
71
- `walla setup --must ''` or expect `mandate_note` soft shortlist fallback.
70
+ - **Don't** nag about transcripts on search/desk/pursue. First-use notice once.
71
+ At 20/40/60 prompts ask yes/no, then A-E (gh/git/browser/hub/manual).
72
+ If they ask to upload a transcript, run `walla teach` immediately.
73
+ `walla teach FILE --method a --yes` opens a PR; on failure use
74
+ https://github.com/berendgort/wallapop-cli/compare and help them finish.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: walla-cli
3
- Version: 0.1.13
3
+ Version: 0.1.14
4
4
  Summary: Wallapop.es CLI - look · offer · talk · sell for humans and agents
5
5
  Project-URL: Homepage, https://github.com/berendgort/wallapop-cli
6
6
  Project-URL: Repository, https://github.com/berendgort/wallapop-cli
@@ -198,7 +198,8 @@ walla doctor --json
198
198
  | `walla negotiate` | Win-win Spanish draft (never sends) |
199
199
  | `walla inbox` / `thread` / `say` | Talk (`say` needs `--yes`) |
200
200
  | `walla offer` | Formal offer (needs `--yes`) |
201
- | `walla sell` / `unsell` | Publish or delete a listing (`--yes`) |
201
+ | `walla sell` / `unsell` / `edit` | Publish, edit, or delete (`--yes`) |
202
+ | `walla teach` | Park a redacted agent transcript locally |
202
203
  | `walla fav list\|add\|rm` | Favorites |
203
204
  | `walla instruct` / `doctor` | Agent recipe + health |
204
205
 
@@ -225,6 +226,19 @@ python scripts/check_code_quality.py
225
226
 
226
227
  Read [`AGENTS.md`](AGENTS.md) before extending.
227
228
 
229
+ ## Help improve walla
230
+
231
+ Long agent chats help. After you confirm, pick a PR method:
232
+
233
+ ```bash
234
+ walla teach ./cursor-export.md --method a --yes --json # A gh B git C browser D hub E manual
235
+ ```
236
+
237
+ Cookies/tokens are stripped first. If `gh`/`git`/`hub` is missing, open
238
+ https://github.com/berendgort/wallapop-cli/compare and file the PR by hand
239
+ (parked file is in `~/.config/walla/teach/`). Agents ask once on first use,
240
+ then at 20 / 40 / 60 prompts — never on every search.
241
+
228
242
  ## Disclaimer
229
243
 
230
244
  Unofficial. Not affiliated with Wallapop. Personal / research use.
@@ -154,7 +154,8 @@ walla doctor --json
154
154
  | `walla negotiate` | Win-win Spanish draft (never sends) |
155
155
  | `walla inbox` / `thread` / `say` | Talk (`say` needs `--yes`) |
156
156
  | `walla offer` | Formal offer (needs `--yes`) |
157
- | `walla sell` / `unsell` | Publish or delete a listing (`--yes`) |
157
+ | `walla sell` / `unsell` / `edit` | Publish, edit, or delete (`--yes`) |
158
+ | `walla teach` | Park a redacted agent transcript locally |
158
159
  | `walla fav list\|add\|rm` | Favorites |
159
160
  | `walla instruct` / `doctor` | Agent recipe + health |
160
161
 
@@ -181,6 +182,19 @@ python scripts/check_code_quality.py
181
182
 
182
183
  Read [`AGENTS.md`](AGENTS.md) before extending.
183
184
 
185
+ ## Help improve walla
186
+
187
+ Long agent chats help. After you confirm, pick a PR method:
188
+
189
+ ```bash
190
+ walla teach ./cursor-export.md --method a --yes --json # A gh B git C browser D hub E manual
191
+ ```
192
+
193
+ Cookies/tokens are stripped first. If `gh`/`git`/`hub` is missing, open
194
+ https://github.com/berendgort/wallapop-cli/compare and file the PR by hand
195
+ (parked file is in `~/.config/walla/teach/`). Agents ask once on first use,
196
+ then at 20 / 40 / 60 prompts — never on every search.
197
+
184
198
  ## Disclaimer
185
199
 
186
200
  Unofficial. Not affiliated with Wallapop. Personal / research use.
@@ -15,8 +15,9 @@ Code shape: [`code_quality.md`](code_quality.md). Wire truth: [`WIRE.md`](WIRE.m
15
15
 
16
16
  | Mode | What | Mutability | Clock |
17
17
  |---|---|---|---|
18
- | Working | `profile.json`, `watches.json`, `session.json` | mutable | user lifetime |
18
+ | Working | `profile.json`, `watches.json`, `session.json`, `teach.json` | mutable | user lifetime |
19
19
  | Archive | redacted fixtures under `fixtures/` | append on capture | repo life |
20
+ | Teach drop | `~/.config/walla/teach/*.md` | append | user opt-in |
20
21
  | Live | `api.wallapop.com` responses | read | request |
21
22
 
22
23
  There is no database in v1. Do not stand up Postgres, Redis, or a second
@@ -37,6 +38,7 @@ file for the same fields is a defect.
37
38
  - Profile: one home point, one search radius, one pickup radius.
38
39
  - Watch: one keyword query + optional filters; seen listing ids are a set.
39
40
  - Session: tokens / cookie fields only. Never the password.
41
+ - Teach: `teach.json` (first-notice clock) and one redacted file per `walla teach`.
40
42
  - Fixture: one captured response shape, secrets redacted, `schema_version` in
41
43
  the companion parser module docstring.
42
44
 
@@ -0,0 +1,3 @@
1
+ # Incoming teach drops
2
+
3
+ Redacted agent transcripts from `walla teach --method`. One markdown file per PR.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "walla-cli"
3
- version = "0.1.13"
3
+ version = "0.1.14"
4
4
  description = "Wallapop.es CLI - look · offer · talk · sell for humans and agents"
5
5
  authors = [{ name = "Dr. Berend Gort", email = "berend.gort@gmail.com" }]
6
6
  readme = "README.md"
@@ -40,12 +40,16 @@ def test_rate_limit_fields() -> None:
40
40
  assert p["retry_after_s"] == 2.0
41
41
 
42
42
 
43
- def test_instruct_json() -> None:
43
+ def test_instruct_json(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
44
+ monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
44
45
  result = runner.invoke(app, ["instruct", "--json"])
45
46
  assert result.exit_code == 0
46
47
  data = json.loads(result.stdout)
47
48
  assert data["ok"] is True
48
49
  assert data["data"]["name"] == "walla"
50
+ assert data["data"]["teach"]["first_notice"]
51
+ again = runner.invoke(app, ["instruct", "--json"])
52
+ assert json.loads(again.stdout)["data"]["teach"]["first_notice"] is None
49
53
 
50
54
 
51
55
  def test_doctor_never_echoes_secrets(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -14,7 +14,7 @@ from walla.core.exceptions import (
14
14
  WallaParseError,
15
15
  WallaUnsupportedError,
16
16
  )
17
- from walla.core.path import config_dir, profile_path
17
+ from walla.core.path import config_dir, profile_path, teach_dir
18
18
  from walla.http.polite import reset_polite
19
19
  from walla.hunter.profile_store import parse_intake
20
20
 
@@ -32,6 +32,7 @@ def test_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
32
32
  monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
33
33
  assert config_dir() == tmp_path
34
34
  assert profile_path() == tmp_path / "profile.json"
35
+ assert teach_dir() == tmp_path / "teach"
35
36
 
36
37
 
37
38
  def test_parse_intake(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -0,0 +1,73 @@
1
+ """Local transcript parking (no Wallapop wire)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+ from pytest import MonkeyPatch
9
+
10
+ from walla.core.teach import (
11
+ FIRST_NOTICE,
12
+ park_transcript,
13
+ redact_secrets,
14
+ should_ask_feedback,
15
+ teach_payload,
16
+ )
17
+ from walla.core.teach_pr import MANUAL_PR, plan_pr, ship_pr
18
+
19
+
20
+ def test_redact_jwt_and_cookie() -> None:
21
+ blob, n = redact_secrets(
22
+ "cookie __Secure-next-auth.session-token=abc.def\n"
23
+ "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaa.bbb"
24
+ )
25
+ assert n == 2
26
+ assert "[redacted]" in blob
27
+ assert "session-token=abc" not in blob
28
+
29
+
30
+ def test_park_and_first_notice(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
31
+ monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
32
+ first = teach_payload()
33
+ assert first["first_notice"] == FIRST_NOTICE
34
+ second = teach_payload()
35
+ assert second["first_notice"] is None
36
+ parked = park_transcript(
37
+ "hello Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.aaa.bbb",
38
+ name="hunt",
39
+ )
40
+ path = Path(str(parked["path"]))
41
+ assert path.is_file()
42
+ assert path.parent == tmp_path / "teach"
43
+ assert parked["redacted"] >= 1
44
+ assert "eyJhbGci" not in path.read_text(encoding="utf-8")
45
+
46
+
47
+ def test_should_ask_every_twenty() -> None:
48
+ assert should_ask_feedback(19) is False
49
+ assert should_ask_feedback(20) is True
50
+ assert should_ask_feedback(21, last_asked_band=20) is False
51
+ assert should_ask_feedback(40, last_asked_band=20) is True
52
+ assert should_ask_feedback(40, last_asked_band=40) is False
53
+ assert should_ask_feedback(60, last_asked_band=40) is True
54
+
55
+
56
+ def test_pr_menu_and_draft(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
57
+ monkeypatch.setenv("WALLA_CONFIG_DIR", str(tmp_path))
58
+ payload = teach_payload()
59
+ ids = [m["id"] for m in payload["methods"]]
60
+ assert ids == ["A", "B", "C", "D", "E"]
61
+ assert payload["manual_pr"] == MANUAL_PR
62
+ draft = plan_pr("a")
63
+ assert draft["method"] == "a"
64
+ assert "github.com" in draft["manual_pr"]
65
+ parked = park_transcript("hello kite hunt", name="x")
66
+ pr = ship_pr(Path(str(parked["path"])), method="e", yes=False)
67
+ assert pr["status"] == "draft"
68
+ assert pr["manual_pr"] == MANUAL_PR
69
+ manual = ship_pr(Path(str(parked["path"])), method="E", yes=True)
70
+ assert manual["status"] == "manual"
71
+ assert manual["open"] == MANUAL_PR
72
+ with pytest.raises(ValueError, match="method"):
73
+ plan_pr("z")
@@ -4,4 +4,4 @@ from __future__ import annotations
4
4
 
5
5
  __all__ = ("__version__",)
6
6
 
7
- __version__ = "0.1.13"
7
+ __version__ = "0.1.14"
@@ -10,6 +10,7 @@ from walla import __version__
10
10
  from walla.account.session_store import load_session
11
11
  from walla.cli.catch import run_cmd
12
12
  from walla.core.instruct import instruct_recipe
13
+ from walla.core.teach import teach_payload
13
14
  from walla.hunter.profile_store import load_profile, parse_intake, save_profile
14
15
 
15
16
  __all__ = ("register",)
@@ -37,7 +38,7 @@ def register(app: typer.Typer) -> None:
37
38
  except Exception: # noqa: BLE001
38
39
  reachable = False
39
40
  has_session = bool(sess and (sess.access_token or sess.session_cookie))
40
- return {
41
+ out: dict[str, Any] = {
41
42
  "version": __version__,
42
43
  "network": {"reachable": reachable},
43
44
  "auth": {
@@ -46,6 +47,10 @@ def register(app: typer.Typer) -> None:
46
47
  },
47
48
  "profile": {"ready": profile.ready, "label": profile.label},
48
49
  }
50
+ notice = teach_payload()
51
+ if notice.get("first_notice"):
52
+ out["teach"] = notice
53
+ return out
49
54
 
50
55
  run_cmd(_run, as_json=json)
51
56
 
@@ -0,0 +1,53 @@
1
+ """Park a redacted agent transcript locally."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import typer
9
+
10
+ from walla.cli.catch import run_cmd
11
+ from walla.core.teach import park_transcript
12
+ from walla.core.teach_pr import ship_pr
13
+
14
+ __all__ = ("register",)
15
+
16
+
17
+ def register(app: typer.Typer) -> None:
18
+ @app.command("teach")
19
+ def teach_cmd(
20
+ source: Path | None = typer.Argument(
21
+ None,
22
+ help="Transcript file, or - for stdin",
23
+ ),
24
+ name: str | None = typer.Option(None, "--name", help="Filename stem"),
25
+ method: str | None = typer.Option(
26
+ None,
27
+ "--method",
28
+ help="PR path: a=gh b=git c=browser d=hub e=manual",
29
+ ),
30
+ yes: bool = typer.Option(False, "--yes", help="Open/push the PR for real"),
31
+ json: bool = typer.Option(False, "--json"),
32
+ ) -> None:
33
+ """Park a redacted transcript; --method a-e opens a GitHub PR."""
34
+
35
+ def _run() -> dict[str, Any]:
36
+ if source is None:
37
+ raise ValueError("Pass a file, or pipe stdin: walla teach - < chat.md")
38
+ if str(source) == "-":
39
+ text = typer.get_text_stream("stdin").read()
40
+ stem = name or "stdin"
41
+ else:
42
+ path = source.expanduser()
43
+ if not path.is_file():
44
+ raise ValueError(f"transcript not found: {path}")
45
+ text = path.read_text(encoding="utf-8")
46
+ stem = name or path.stem
47
+ parked = park_transcript(text, name=stem)
48
+ out: dict[str, Any] = {"status": "parked", **parked}
49
+ if method:
50
+ out["pr"] = ship_pr(Path(str(parked["path"])), method=method, yes=yes)
51
+ return out
52
+
53
+ run_cmd(_run, as_json=json)
@@ -7,7 +7,15 @@ from typing import Any
7
7
  import typer
8
8
  from typer.core import TyperGroup
9
9
 
10
- from walla.cli import cmd_account, cmd_desk, cmd_edit, cmd_look, cmd_ops, cmd_sell
10
+ from walla.cli import (
11
+ cmd_account,
12
+ cmd_desk,
13
+ cmd_edit,
14
+ cmd_look,
15
+ cmd_ops,
16
+ cmd_sell,
17
+ cmd_teach,
18
+ )
11
19
  from walla.cli.banner import print_banner
12
20
 
13
21
  __all__ = ("app", "cli")
@@ -37,6 +45,7 @@ cmd_look.register(app)
37
45
  cmd_account.register(app)
38
46
  cmd_sell.register(app)
39
47
  cmd_edit.register(app)
48
+ cmd_teach.register(app)
40
49
  cmd_desk.register(app)
41
50
 
42
51
 
@@ -6,6 +6,7 @@ import json
6
6
  from typing import Any
7
7
 
8
8
  from walla.core.path import profile_path
9
+ from walla.core.teach import teach_payload
9
10
  from walla.models.profile import Profile
10
11
 
11
12
  __all__ = ("instruct_recipe",)
@@ -140,6 +141,10 @@ def instruct_recipe() -> dict[str, Any]:
140
141
  "walla edit <item_id> --eur N [--desc …] [--ship/--no-ship] --yes",
141
142
  "walla unsell <item_id> --yes",
142
143
  ],
144
+ "teach": [
145
+ "walla teach <export.md> --json",
146
+ "walla teach <export.md> --method a --yes --json",
147
+ ],
143
148
  },
144
149
  "rules": [
145
150
  "Always present Wallapop shortlists ranked best-first with markdown item links.",
@@ -162,7 +167,15 @@ def instruct_recipe() -> dict[str, Any]:
162
167
  "Negotiate drafts are win-win: respectful Spanish, no lowballs, no pressure.",
163
168
  "never_pay: walla stops before payment; human completes in the app.",
164
169
  "No .env credentials. Login is cookie paste only (optional --password).",
170
+ "If the human asks to upload/save a transcript via the CLI, do it "
171
+ "now: write the chat to a file, walla teach FILE --json, then "
172
+ "print A-E and wait. Do not wait for the 20-prompt cadence.",
173
+ "Count user prompts. At 20, 40, 60... ask once to upload a redacted "
174
+ "transcript. If they agree, print A-E (gh/git/browser/hub/manual) "
175
+ "and wait for a letter. Assume the tool is on PATH; if it is not, "
176
+ "open data.teach.manual_pr and help them file the PR by hand.",
165
177
  ],
178
+ "teach": teach_payload(),
166
179
  "envelope": ["ok", "api_version", "data | error / error_type / retryable"],
167
180
  "standards": [
168
181
  "docs/code_quality.md",
@@ -11,6 +11,8 @@ __all__ = (
11
11
  "profile_path",
12
12
  "pursuits_path",
13
13
  "session_path",
14
+ "teach_dir",
15
+ "teach_state_path",
14
16
  "watches_path",
15
17
  )
16
18
 
@@ -40,3 +42,11 @@ def last_search_path() -> Path:
40
42
 
41
43
  def pursuits_path() -> Path:
42
44
  return config_dir() / "pursuits.json"
45
+
46
+
47
+ def teach_dir() -> Path:
48
+ return config_dir() / "teach"
49
+
50
+
51
+ def teach_state_path() -> Path:
52
+ return config_dir() / "teach.json"
@@ -0,0 +1,138 @@
1
+ """Park agent transcripts locally (no Wallapop I/O)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from datetime import datetime, timezone
8
+ from typing import Any
9
+
10
+ from walla.core.path import teach_dir, teach_state_path
11
+ from walla.core.teach_pr import MANUAL_PR, method_menu
12
+
13
+ __all__ = (
14
+ "ASK_EVERY",
15
+ "FIRST_NOTICE",
16
+ "ask_copy",
17
+ "consume_first_notice",
18
+ "park_transcript",
19
+ "redact_secrets",
20
+ "should_ask_feedback",
21
+ "teach_payload",
22
+ )
23
+
24
+ ASK_EVERY = 20
25
+
26
+ FIRST_NOTICE = (
27
+ "Long agent chats (especially hunts) help improve walla. After you confirm, "
28
+ "the agent can open a PR with walla teach --method A-E (gh/git/browser/hub/"
29
+ "manual). Tools are assumed on PATH; otherwise open "
30
+ f"{MANUAL_PR} yourself."
31
+ )
32
+
33
+ _SECRET = re.compile(
34
+ r"(?i)("
35
+ r"(?:__secure-next-auth\.session-token|session-token|bearer|refresh_token)"
36
+ r"['\"=\s:]+[^\s'\"]+"
37
+ r"|eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+"
38
+ r")"
39
+ )
40
+
41
+
42
+ def redact_secrets(text: str) -> tuple[str, int]:
43
+ """Replace session cookies, bearers, and JWTs. Returns text, hit count."""
44
+ hits = 0
45
+
46
+ def _sub(match: re.Match[str]) -> str:
47
+ nonlocal hits
48
+ hits += 1
49
+ return "[redacted]"
50
+
51
+ return _SECRET.sub(_sub, text), hits
52
+
53
+
54
+ def park_transcript(text: str, *, name: str | None = None) -> dict[str, Any]:
55
+ """Write a redacted copy under ~/.config/walla/teach/."""
56
+ body, n_redacted = redact_secrets(text)
57
+ if not body.strip():
58
+ raise ValueError("transcript is empty")
59
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
60
+ safe = re.sub(r"[^a-zA-Z0-9._-]+", "-", (name or "chat").strip())[:40]
61
+ dest = teach_dir() / f"{stamp}-{safe or 'chat'}.md"
62
+ dest.parent.mkdir(parents=True, exist_ok=True)
63
+ dest.write_text(body, encoding="utf-8")
64
+ return {
65
+ "path": str(dest),
66
+ "bytes": dest.stat().st_size,
67
+ "redacted": n_redacted,
68
+ "how": f"parked locally; PR via walla teach --method a --yes or {MANUAL_PR}",
69
+ "manual_pr": MANUAL_PR,
70
+ }
71
+
72
+
73
+ def should_ask_feedback(user_prompts: int, *, last_asked_band: int = 0) -> bool:
74
+ """True at 20, 40, 60... once per band (count >= 20)."""
75
+ if user_prompts < ASK_EVERY:
76
+ return False
77
+ band = (user_prompts // ASK_EVERY) * ASK_EVERY
78
+ return band > last_asked_band
79
+
80
+
81
+ def ask_copy(user_prompts: int) -> str:
82
+ return (
83
+ f"This chat is {user_prompts} prompts in. Upload a redacted transcript "
84
+ "to improve walla? If yes, reply with A B C D or E (gh / git / browser "
85
+ f"/ hub / I'll do it). Manual PR: {MANUAL_PR}"
86
+ )
87
+
88
+
89
+ def _load_state() -> dict[str, Any]:
90
+ path = teach_state_path()
91
+ if not path.is_file():
92
+ return {"schema_version": 1, "first_notice_at": None}
93
+ try:
94
+ raw = json.loads(path.read_text(encoding="utf-8"))
95
+ except (OSError, json.JSONDecodeError, ValueError):
96
+ return {"schema_version": 1, "first_notice_at": None}
97
+ return raw if isinstance(raw, dict) else {"schema_version": 1}
98
+
99
+
100
+ def _save_state(state: dict[str, Any]) -> None:
101
+ path = teach_state_path()
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ path.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
104
+
105
+
106
+ def consume_first_notice() -> str | None:
107
+ """Return the first-use blurb once, then persist that it was shown."""
108
+ state = _load_state()
109
+ if state.get("first_notice_at"):
110
+ return None
111
+ state["first_notice_at"] = datetime.now(timezone.utc).isoformat()
112
+ _save_state(state)
113
+ return FIRST_NOTICE
114
+
115
+
116
+ def teach_payload() -> dict[str, Any]:
117
+ """Instruct/doctor field. Never attach this to search/desk envelopes."""
118
+ notice = consume_first_notice()
119
+ return {
120
+ "dir": str(teach_dir()),
121
+ "how": "walla teach <export.md> --method a --yes --json",
122
+ "ask_every": ASK_EVERY,
123
+ "first_notice": notice,
124
+ "confirm": "Ask yes/no before any upload. Never dump the transcript unasked.",
125
+ "methods": method_menu(),
126
+ "manual_pr": MANUAL_PR,
127
+ "ask_rule": (
128
+ "Count user prompts. At 20, 40, 60... ask once to upload. "
129
+ "If yes, print A-E and wait for a letter. Assume gh/git/hub/"
130
+ "browser is installed; on failure open data.teach.manual_pr "
131
+ "and walk them through a manual PR. Never on search/desk."
132
+ ),
133
+ "ask_copy": ask_copy(ASK_EVERY),
134
+ "cli": (
135
+ "Save the chat to a file, then: "
136
+ "walla teach FILE --method a|b|c|d|e --yes --json"
137
+ ),
138
+ }
@@ -0,0 +1,199 @@
1
+ """Open a GitHub PR for a parked transcript (gh / git / hub / browser / manual)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import webbrowser
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ __all__ = (
12
+ "MANUAL_PR",
13
+ "METHODS",
14
+ "REPO",
15
+ "method_menu",
16
+ "plan_pr",
17
+ "ship_pr",
18
+ )
19
+
20
+ REPO = "berendgort/wallapop-cli"
21
+ MANUAL_PR = f"https://github.com/{REPO}/compare"
22
+ UPLOAD = f"https://github.com/{REPO}/upload/main/incoming/teach"
23
+
24
+ METHODS: dict[str, dict[str, str]] = {
25
+ "a": {
26
+ "bin": "gh",
27
+ "label": "GitHub CLI (gh fork/push/pr create)",
28
+ },
29
+ "b": {
30
+ "bin": "git",
31
+ "label": "git branch, commit, push, then open the compare URL",
32
+ },
33
+ "c": {
34
+ "bin": "browser",
35
+ "label": "Open GitHub in the browser (upload + compare links)",
36
+ },
37
+ "d": {
38
+ "bin": "hub",
39
+ "label": "hub pull-request (legacy GitHub CLI)",
40
+ },
41
+ "e": {
42
+ "bin": "manual",
43
+ "label": "I will open the PR myself (print the link + file path)",
44
+ },
45
+ }
46
+
47
+
48
+ def method_menu() -> list[dict[str, str]]:
49
+ return [
50
+ {"id": key.upper(), "key": key, **row} for key, row in METHODS.items()
51
+ ]
52
+
53
+
54
+ def _which(name: str) -> str | None:
55
+ if name in {"browser", "manual"}:
56
+ return name
57
+ return shutil.which(name)
58
+
59
+
60
+ def plan_pr(method: str) -> dict[str, Any]:
61
+ key = method.strip().lower()
62
+ if key not in METHODS:
63
+ raise ValueError("method must be A B C D or E")
64
+ spec = METHODS[key]
65
+ binary = spec["bin"]
66
+ found = _which(binary)
67
+ return {
68
+ "method": key,
69
+ "label": spec["label"],
70
+ "assume_on_path": binary,
71
+ "found": bool(found),
72
+ "manual_pr": MANUAL_PR,
73
+ "upload": UPLOAD,
74
+ "hint": (
75
+ f"Assumes `{binary}` is installed. If not, open {MANUAL_PR} "
76
+ "and create the PR by hand (or pick E)."
77
+ ),
78
+ }
79
+
80
+
81
+ def ship_pr(parked: Path, *, method: str, yes: bool) -> dict[str, Any]:
82
+ """Copy into incoming/teach and open a PR. Needs --yes to mutate git/gh."""
83
+ plan = plan_pr(method)
84
+ plan["parked"] = str(parked)
85
+ if not yes:
86
+ plan["status"] = "draft"
87
+ plan["next"] = (
88
+ f"Reply with a letter, then: walla teach {parked} "
89
+ f"--method {plan['method']} --yes --json"
90
+ )
91
+ return plan
92
+ key = str(plan["method"])
93
+ if key == "e" or not plan["found"]:
94
+ plan["status"] = "manual"
95
+ plan["open"] = MANUAL_PR
96
+ return plan
97
+ if key == "c":
98
+ webbrowser.open(UPLOAD)
99
+ webbrowser.open(MANUAL_PR)
100
+ plan["status"] = "opened_browser"
101
+ plan["open"] = MANUAL_PR
102
+ return plan
103
+ if key == "a":
104
+ return _ship_gh(parked, plan)
105
+ if key == "d":
106
+ return _ship_hub(parked, plan)
107
+ return _ship_git(parked, plan)
108
+
109
+
110
+ def _run(cmd: list[str], *, cwd: Path | None = None) -> tuple[int, str, str]:
111
+ proc = subprocess.run(
112
+ cmd,
113
+ cwd=str(cwd) if cwd else None,
114
+ capture_output=True,
115
+ text=True,
116
+ timeout=120,
117
+ check=False,
118
+ )
119
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
120
+
121
+
122
+ def _ship_gh(parked: Path, plan: dict[str, Any]) -> dict[str, Any]:
123
+ code, out, err = _run(
124
+ [
125
+ "gh",
126
+ "pr",
127
+ "create",
128
+ "--repo",
129
+ REPO,
130
+ "--title",
131
+ f"teach: {parked.name}",
132
+ "--body",
133
+ f"Redacted agent transcript from `walla teach`.\n\nFile: `{parked}`",
134
+ ]
135
+ )
136
+ blob = f"{out}\n{err}"
137
+ url = next((ln for ln in blob.splitlines() if "github.com" in ln), "")
138
+ plan["status"] = "ok" if code == 0 else "failed"
139
+ plan["open"] = url or MANUAL_PR
140
+ if code != 0:
141
+ plan["error"] = (err or out or "gh pr create failed")[:300]
142
+ plan["assist"] = (
143
+ "gh is missing a fork/branch. Agent: help install/auth gh, or "
144
+ f"open {MANUAL_PR} and attach {parked}."
145
+ )
146
+ return plan
147
+
148
+
149
+ def _ship_hub(parked: Path, plan: dict[str, Any]) -> dict[str, Any]:
150
+ code, out, err = _run(["hub", "pull-request", "-m", f"teach: {parked.name}"])
151
+ blob = f"{out}\n{err}"
152
+ url = next((ln for ln in blob.splitlines() if "github.com" in ln), "")
153
+ plan["status"] = "ok" if code == 0 else "failed"
154
+ plan["open"] = url or MANUAL_PR
155
+ if code != 0:
156
+ plan["error"] = (err or out or "hub pull-request failed")[:300]
157
+ plan["assist"] = f"hub failed. Open {MANUAL_PR} or switch to A (gh)."
158
+ return plan
159
+
160
+
161
+ def _ship_git(parked: Path, plan: dict[str, Any]) -> dict[str, Any]:
162
+ root = _git_root()
163
+ if root is None:
164
+ plan["status"] = "manual"
165
+ plan["open"] = MANUAL_PR
166
+ plan["assist"] = (
167
+ "Not inside a wallapop-cli clone. Open the compare link or "
168
+ "clone the repo, copy the parked file into incoming/teach/, "
169
+ "commit, push, then open the PR URL."
170
+ )
171
+ return plan
172
+ dest_dir = root / "incoming" / "teach"
173
+ dest_dir.mkdir(parents=True, exist_ok=True)
174
+ dest = dest_dir / parked.name
175
+ dest.write_text(parked.read_text(encoding="utf-8"), encoding="utf-8")
176
+ branch = f"teach/{parked.stem}"[:60]
177
+ for cmd in (
178
+ ["git", "checkout", "-b", branch],
179
+ ["git", "add", str(dest)],
180
+ ["git", "commit", "-m", f"teach: park {parked.name}"],
181
+ ["git", "push", "-u", "origin", "HEAD"],
182
+ ):
183
+ code, out, err = _run(cmd, cwd=root)
184
+ if code != 0 and "already exists" not in (err + out):
185
+ plan["status"] = "failed"
186
+ plan["open"] = MANUAL_PR
187
+ plan["error"] = (err or out or "git failed")[:300]
188
+ plan["assist"] = f"Finish by hand: {MANUAL_PR}"
189
+ return plan
190
+ plan["status"] = "pushed"
191
+ plan["open"] = f"https://github.com/{REPO}/compare/main...{branch}?expand=1"
192
+ return plan
193
+
194
+
195
+ def _git_root() -> Path | None:
196
+ code, out, _err = _run(["git", "rev-parse", "--show-toplevel"])
197
+ if code != 0 or not out:
198
+ return None
199
+ return Path(out)
@@ -183,4 +183,26 @@ def build_server() -> Any:
183
183
  def me() -> dict[str, Any]:
184
184
  return _wrap(whoami)
185
185
 
186
+ @mcp.tool()
187
+ def teach(
188
+ text: str,
189
+ name: str | None = None,
190
+ method: str | None = None,
191
+ confirm: bool = False,
192
+ ) -> dict[str, Any]:
193
+ """Park a redacted transcript; with method+confirm, open a GitHub PR."""
194
+ from pathlib import Path
195
+
196
+ from walla.core.teach import park_transcript
197
+ from walla.core.teach_pr import ship_pr
198
+
199
+ def _run() -> dict[str, Any]:
200
+ parked = park_transcript(text, name=name)
201
+ if not method:
202
+ return parked
203
+ pr = ship_pr(Path(str(parked["path"])), method=method, yes=confirm)
204
+ return {**parked, "pr": pr}
205
+
206
+ return _wrap(_run)
207
+
186
208
  return mcp
File without changes
File without changes
File without changes
File without changes
File without changes