hermitcrm 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. hermitcrm/__init__.py +17 -0
  2. hermitcrm/accesslog.py +57 -0
  3. hermitcrm/ask.py +194 -0
  4. hermitcrm/backup.py +590 -0
  5. hermitcrm/bcc.py +1085 -0
  6. hermitcrm/brief.py +144 -0
  7. hermitcrm/calendar_sync.py +544 -0
  8. hermitcrm/capture.py +293 -0
  9. hermitcrm/cli.py +1361 -0
  10. hermitcrm/datafolder.py +567 -0
  11. hermitcrm/default_messages.toml +134 -0
  12. hermitcrm/disclaimer.py +60 -0
  13. hermitcrm/doctor.py +232 -0
  14. hermitcrm/enrich.py +664 -0
  15. hermitcrm/feedback.py +108 -0
  16. hermitcrm/fields.py +277 -0
  17. hermitcrm/filters.py +161 -0
  18. hermitcrm/followups.py +169 -0
  19. hermitcrm/gitops.py +192 -0
  20. hermitcrm/guard.py +93 -0
  21. hermitcrm/help/__init__.py +258 -0
  22. hermitcrm/help/ai-agents.md +161 -0
  23. hermitcrm/help/ask.md +50 -0
  24. hermitcrm/help/backups.md +173 -0
  25. hermitcrm/help/calendar.md +30 -0
  26. hermitcrm/help/cli.md +78 -0
  27. hermitcrm/help/companies.md +109 -0
  28. hermitcrm/help/contacts.md +66 -0
  29. hermitcrm/help/data-format.md +161 -0
  30. hermitcrm/help/disclaimer.md +94 -0
  31. hermitcrm/help/enrich.md +42 -0
  32. hermitcrm/help/extension.md +84 -0
  33. hermitcrm/help/feedback.md +48 -0
  34. hermitcrm/help/import.md +37 -0
  35. hermitcrm/help/index.md +35 -0
  36. hermitcrm/help/interactions.md +74 -0
  37. hermitcrm/help/merge.md +27 -0
  38. hermitcrm/help/messages.md +48 -0
  39. hermitcrm/help/pipeline.md +47 -0
  40. hermitcrm/help/reports.md +29 -0
  41. hermitcrm/help/settings.md +259 -0
  42. hermitcrm/help/tasks.md +49 -0
  43. hermitcrm/importer.py +894 -0
  44. hermitcrm/mcp.py +452 -0
  45. hermitcrm/messaging.py +335 -0
  46. hermitcrm/migrations.py +461 -0
  47. hermitcrm/models.py +1141 -0
  48. hermitcrm/pipeline.py +173 -0
  49. hermitcrm/reports.py +733 -0
  50. hermitcrm/sample.py +61 -0
  51. hermitcrm/schedule.py +626 -0
  52. hermitcrm/scrape.py +680 -0
  53. hermitcrm/secrets.py +269 -0
  54. hermitcrm/setup.py +709 -0
  55. hermitcrm/static/bookmarklet.js +89 -0
  56. hermitcrm/static/favicon.png +0 -0
  57. hermitcrm/static/favicon.svg +8 -0
  58. hermitcrm/static/style.css +680 -0
  59. hermitcrm/static/tokens.css +115 -0
  60. hermitcrm/static/tour.js +125 -0
  61. hermitcrm/store.py +1411 -0
  62. hermitcrm/task_types.py +114 -0
  63. hermitcrm/templates/ask.html +55 -0
  64. hermitcrm/templates/base.html +68 -0
  65. hermitcrm/templates/board.html +75 -0
  66. hermitcrm/templates/calendar.html +92 -0
  67. hermitcrm/templates/companies.html +37 -0
  68. hermitcrm/templates/company.html +137 -0
  69. hermitcrm/templates/company_new.html +17 -0
  70. hermitcrm/templates/contact.html +70 -0
  71. hermitcrm/templates/contact_new.html +60 -0
  72. hermitcrm/templates/contacts.html +28 -0
  73. hermitcrm/templates/disclaimer_modal.html +31 -0
  74. hermitcrm/templates/enrich_preview.html +37 -0
  75. hermitcrm/templates/extension.html +43 -0
  76. hermitcrm/templates/feedback.html +38 -0
  77. hermitcrm/templates/help.html +17 -0
  78. hermitcrm/templates/home.html +93 -0
  79. hermitcrm/templates/icons.html +20 -0
  80. hermitcrm/templates/import.html +45 -0
  81. hermitcrm/templates/import_preview.html +54 -0
  82. hermitcrm/templates/interaction_edit.html +11 -0
  83. hermitcrm/templates/interaction_new.html +7 -0
  84. hermitcrm/templates/macros.html +396 -0
  85. hermitcrm/templates/merge.html +23 -0
  86. hermitcrm/templates/messages.html +44 -0
  87. hermitcrm/templates/report_rows.html +26 -0
  88. hermitcrm/templates/reports.html +124 -0
  89. hermitcrm/templates/sample_remove.html +24 -0
  90. hermitcrm/templates/settings.html +392 -0
  91. hermitcrm/templates/tasks.html +53 -0
  92. hermitcrm/templates/to_file.html +32 -0
  93. hermitcrm/templates/welcome.html +65 -0
  94. hermitcrm/updates.py +277 -0
  95. hermitcrm/usertheme.py +88 -0
  96. hermitcrm/web.py +3021 -0
  97. hermitcrm/welcome.py +128 -0
  98. hermitcrm-0.5.0.dist-info/METADATA +525 -0
  99. hermitcrm-0.5.0.dist-info/RECORD +104 -0
  100. hermitcrm-0.5.0.dist-info/WHEEL +5 -0
  101. hermitcrm-0.5.0.dist-info/entry_points.txt +2 -0
  102. hermitcrm-0.5.0.dist-info/licenses/LICENSE +202 -0
  103. hermitcrm-0.5.0.dist-info/licenses/NOTICE +35 -0
  104. hermitcrm-0.5.0.dist-info/top_level.txt +1 -0
hermitcrm/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ # Copyright 2026 Gijs Bos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Hermit CRM: your CRM is a folder of Markdown files in git."""
16
+
17
+ __version__ = "0.5.0"
hermitcrm/accesslog.py ADDED
@@ -0,0 +1,57 @@
1
+ # Copyright 2026 Gijs Bos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """The serve log, without what a capture read off a page.
16
+
17
+ The bookmarklet hands the running app what it read on a profile -- a name, a
18
+ headline, an employer -- in the query string of a local address, because a
19
+ new tab is the one thing a bookmarklet may open from any page. uvicorn writes
20
+ every address it answers to the serve log, so without this the log would keep
21
+ a copy of each person captured, outside the data folder and its history.
22
+
23
+ The request line stays (path, status, time); only the query goes, and only on
24
+ the capture paths. Everything else is logged exactly as uvicorn logs it.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import copy
30
+ import logging
31
+
32
+ from uvicorn.config import LOGGING_CONFIG
33
+
34
+ # The page's own words arrive here; /capture/new is the old bookmarklet's path.
35
+ QUIET_PATHS = ("/extension/new", "/capture/new")
36
+
37
+
38
+ class QuietCaptures(logging.Filter):
39
+ """Drop the query of a capture request from uvicorn's access line."""
40
+
41
+ def filter(self, record: logging.LogRecord) -> bool:
42
+ args = record.args
43
+ # uvicorn: (client, method, path with query, http version, status)
44
+ if isinstance(args, tuple) and len(args) == 5 and isinstance(args[2], str):
45
+ path, _, query = args[2].partition("?")
46
+ if query and path in QUIET_PATHS:
47
+ record.args = (*args[:2], path + "?…", *args[3:])
48
+ return True
49
+
50
+
51
+ def log_config() -> dict:
52
+ """uvicorn's own logging setup, with QuietCaptures on its access log."""
53
+ config = copy.deepcopy(LOGGING_CONFIG)
54
+ config.setdefault("filters", {})["quiet_captures"] = {"()": QuietCaptures}
55
+ access = config["loggers"].setdefault("uvicorn.access", {})
56
+ access["filters"] = [*access.get("filters", []), "quiet_captures"]
57
+ return config
hermitcrm/ask.py ADDED
@@ -0,0 +1,194 @@
1
+ # Copyright 2026 Gijs Bos
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Ask the Hermit: answer a question about the CRM with the configured AI CLI.
16
+
17
+ Two steps, cheapest first. The page the question was asked on is rendered to
18
+ plain text and the model answers from that text alone, without tools. Only
19
+ when it says the page is not enough does a second run start inside the data
20
+ folder with read-only tools (files plus `hermitcrm show/digest/report`), where
21
+ the folder's CLAUDE.md tells an agent how to read the CRM cheaply. The model
22
+ tier (medium by default, strong for "Retry with ...") comes from the Enricher.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import sys
28
+ from dataclasses import dataclass, field
29
+ from html.parser import HTMLParser
30
+ from pathlib import Path
31
+
32
+ from .enrich import EnrichError, Enricher, model_label
33
+
34
+ PAGE_LIMIT = 40_000 # characters of page text sent with the first step
35
+
36
+ # Tags whose content is chrome or form plumbing, not what the page says.
37
+ SKIP_TAGS = {"script", "style", "nav", "header", "footer", "button", "svg", "template",
38
+ "noscript"}
39
+ BLOCK_TAGS = {"p", "div", "section", "article", "main", "table", "tr", "li", "ul",
40
+ "ol", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "details", "summary",
41
+ "br", "form", "label", "dl", "dt", "dd", "blockquote"}
42
+ VOID_TAGS = {"br", "img", "input", "meta", "link", "hr", "col", "source", "wbr"}
43
+
44
+ # Claude Code --allowedTools for the whole-CRM step: read-only.
45
+ CRM_TOOLS = ("Read,Grep,Glob,Bash(hermitcrm show:*),Bash(hermitcrm digest:*),"
46
+ "Bash(hermitcrm report:*),Bash(hermitcrm check:*)")
47
+
48
+ PAGE_SCHEMA = {
49
+ "type": "object",
50
+ "properties": {
51
+ "answerable": {"type": "boolean",
52
+ "description": "true only if the page text is enough to answer"},
53
+ "answer": {"type": "string",
54
+ "description": "the answer in Markdown; empty when not answerable"},
55
+ },
56
+ "required": ["answerable", "answer"],
57
+ "additionalProperties": False,
58
+ }
59
+ CRM_SCHEMA = {
60
+ "type": "object",
61
+ "properties": {
62
+ "answer": {"type": "string", "description": "the answer in Markdown"},
63
+ "sources": {"type": "array", "items": {"type": "string"},
64
+ "description": "files or commands the answer relies on"},
65
+ },
66
+ "required": ["answer", "sources"],
67
+ "additionalProperties": False,
68
+ }
69
+
70
+
71
+ class _TextExtractor(HTMLParser):
72
+ def __init__(self) -> None:
73
+ super().__init__(convert_charrefs=True)
74
+ self.parts: list[str] = []
75
+ self.skip = 0
76
+ self.select = "" # name of the <select> we are in, if any
77
+ self.selected = False # inside its selected <option>: that value is page data
78
+
79
+ def handle_starttag(self, tag, attrs):
80
+ if tag == "select":
81
+ self.select = dict(attrs).get("name") or "choice"
82
+ elif tag == "option":
83
+ self.selected = self.select != "" and "selected" in dict(attrs)
84
+ elif tag in SKIP_TAGS and tag not in VOID_TAGS:
85
+ self.skip += 1
86
+ elif tag in BLOCK_TAGS:
87
+ self.parts.append("\n")
88
+ elif tag in ("td", "th"):
89
+ self.parts.append(" | ")
90
+ elif tag == "input" and not self.skip:
91
+ values = dict(attrs)
92
+ if values.get("type") not in ("hidden", "submit", "checkbox", "radio") \
93
+ and values.get("value"):
94
+ self.parts.append(f" [{values.get('name', 'field')}: {values['value']}] ")
95
+
96
+ def handle_endtag(self, tag):
97
+ if tag == "select":
98
+ self.select, self.selected = "", False
99
+ elif tag == "option":
100
+ self.selected = False
101
+ elif tag in SKIP_TAGS and tag not in VOID_TAGS:
102
+ self.skip = max(0, self.skip - 1)
103
+ elif tag in BLOCK_TAGS:
104
+ self.parts.append("\n")
105
+
106
+ def handle_data(self, data):
107
+ if self.skip:
108
+ return
109
+ if self.select: # of a dropdown only the chosen value is content
110
+ if self.selected and data.strip():
111
+ self.parts.append(f" [{self.select}: {data.strip()}] ")
112
+ return
113
+ self.parts.append(data)
114
+
115
+
116
+ def page_text(html: str, limit: int = PAGE_LIMIT) -> str:
117
+ """The visible content of a rendered page as compact text, capped at limit."""
118
+ parser = _TextExtractor()
119
+ parser.feed(html or "")
120
+ parser.close()
121
+ lines = [" ".join(line.split()) for line in "".join(parser.parts).splitlines()]
122
+ text = "\n".join(line for line in lines if line.strip(" |"))
123
+ if len(text) > limit:
124
+ text = text[:limit] + "\n[page text cut off here]"
125
+ return text
126
+
127
+
128
+ def page_prompt(question: str, page: str, text: str) -> str:
129
+ return (
130
+ "You are Hermit, the assistant inside Hermit CRM, a personal sales CRM. "
131
+ f"The user is looking at the page {page} and asks a question about it. "
132
+ "Answer using only the page content below. If the page does not contain "
133
+ "enough to answer reliably, set answerable to false and leave answer empty; "
134
+ "a search of the whole CRM follows. Be concise and concrete; use Markdown "
135
+ "lists or tables when they help.\n\n"
136
+ f"Question: {question}\n\n--- page content ---\n{text}\n--- end of page ---\n"
137
+ )
138
+
139
+
140
+ def crm_prompt(question: str, page: str, text: str) -> str:
141
+ context = text[:4000]
142
+ return (
143
+ "You are Hermit, the assistant inside Hermit CRM. The current directory is "
144
+ "the CRM data folder; CLAUDE.md in it explains the layout and the cheapest "
145
+ "way to read it (PIPELINE.md first, `hermitcrm show <slug>`, `hermitcrm "
146
+ "digest`, `hermitcrm report --md`). Answer the question from that data. "
147
+ "Read only: do not create, edit or delete anything. If the data does not "
148
+ "answer the question, say so plainly. Be concise and concrete.\n\n"
149
+ f"The user asked this while on the page {page} (start of that page below, "
150
+ "for context).\n\n"
151
+ f"Question: {question}\n\n--- page ---\n{context}\n--- end ---\n"
152
+ )
153
+
154
+
155
+ @dataclass
156
+ class Answer:
157
+ question: str
158
+ text: str
159
+ scope: str # "page" or "crm"
160
+ model: str
161
+ tier: str
162
+ sources: list[str] = field(default_factory=list)
163
+
164
+ @property
165
+ def model_label(self) -> str:
166
+ return model_label(self.model)
167
+
168
+
169
+ def ask(enricher: Enricher, question: str, page: str, html: str, data_dir: Path | str,
170
+ scope: str = "auto", timeout: float | None = None) -> Answer:
171
+ """Answer from the page first (scope auto), or go straight to the CRM (scope crm).
172
+
173
+ Raises EnrichError when no AI CLI is available or a run fails.
174
+ """
175
+ question = " ".join((question or "").split())
176
+ if not question:
177
+ raise EnrichError("Type a question first.")
178
+ if not enricher.available:
179
+ raise EnrichError(enricher.unavailable_reason())
180
+ if timeout:
181
+ enricher.timeout = float(timeout)
182
+ text = page_text(html)
183
+ if scope != "crm":
184
+ data = enricher.runner(page_prompt(question, page, text), PAGE_SCHEMA, tools="")
185
+ answer = str(data.get("answer") or "").strip()
186
+ if data.get("answerable") and answer:
187
+ return Answer(question, answer, "page", enricher.model, enricher.tier)
188
+ data = enricher.runner(crm_prompt(question, page, text), CRM_SCHEMA, tools=CRM_TOOLS,
189
+ cwd=str(data_dir), extra_path=str(Path(sys.executable).parent))
190
+ answer = str(data.get("answer") or "").strip()
191
+ if not answer:
192
+ raise EnrichError("The AI returned an empty answer.")
193
+ sources = [str(s) for s in (data.get("sources") or []) if s]
194
+ return Answer(question, answer, "crm", enricher.model, enricher.tier, sources)