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.
- hermitcrm/__init__.py +17 -0
- hermitcrm/accesslog.py +57 -0
- hermitcrm/ask.py +194 -0
- hermitcrm/backup.py +590 -0
- hermitcrm/bcc.py +1085 -0
- hermitcrm/brief.py +144 -0
- hermitcrm/calendar_sync.py +544 -0
- hermitcrm/capture.py +293 -0
- hermitcrm/cli.py +1361 -0
- hermitcrm/datafolder.py +567 -0
- hermitcrm/default_messages.toml +134 -0
- hermitcrm/disclaimer.py +60 -0
- hermitcrm/doctor.py +232 -0
- hermitcrm/enrich.py +664 -0
- hermitcrm/feedback.py +108 -0
- hermitcrm/fields.py +277 -0
- hermitcrm/filters.py +161 -0
- hermitcrm/followups.py +169 -0
- hermitcrm/gitops.py +192 -0
- hermitcrm/guard.py +93 -0
- hermitcrm/help/__init__.py +258 -0
- hermitcrm/help/ai-agents.md +161 -0
- hermitcrm/help/ask.md +50 -0
- hermitcrm/help/backups.md +173 -0
- hermitcrm/help/calendar.md +30 -0
- hermitcrm/help/cli.md +78 -0
- hermitcrm/help/companies.md +109 -0
- hermitcrm/help/contacts.md +66 -0
- hermitcrm/help/data-format.md +161 -0
- hermitcrm/help/disclaimer.md +94 -0
- hermitcrm/help/enrich.md +42 -0
- hermitcrm/help/extension.md +84 -0
- hermitcrm/help/feedback.md +48 -0
- hermitcrm/help/import.md +37 -0
- hermitcrm/help/index.md +35 -0
- hermitcrm/help/interactions.md +74 -0
- hermitcrm/help/merge.md +27 -0
- hermitcrm/help/messages.md +48 -0
- hermitcrm/help/pipeline.md +47 -0
- hermitcrm/help/reports.md +29 -0
- hermitcrm/help/settings.md +259 -0
- hermitcrm/help/tasks.md +49 -0
- hermitcrm/importer.py +894 -0
- hermitcrm/mcp.py +452 -0
- hermitcrm/messaging.py +335 -0
- hermitcrm/migrations.py +461 -0
- hermitcrm/models.py +1141 -0
- hermitcrm/pipeline.py +173 -0
- hermitcrm/reports.py +733 -0
- hermitcrm/sample.py +61 -0
- hermitcrm/schedule.py +626 -0
- hermitcrm/scrape.py +680 -0
- hermitcrm/secrets.py +269 -0
- hermitcrm/setup.py +709 -0
- hermitcrm/static/bookmarklet.js +89 -0
- hermitcrm/static/favicon.png +0 -0
- hermitcrm/static/favicon.svg +8 -0
- hermitcrm/static/style.css +680 -0
- hermitcrm/static/tokens.css +115 -0
- hermitcrm/static/tour.js +125 -0
- hermitcrm/store.py +1411 -0
- hermitcrm/task_types.py +114 -0
- hermitcrm/templates/ask.html +55 -0
- hermitcrm/templates/base.html +68 -0
- hermitcrm/templates/board.html +75 -0
- hermitcrm/templates/calendar.html +92 -0
- hermitcrm/templates/companies.html +37 -0
- hermitcrm/templates/company.html +137 -0
- hermitcrm/templates/company_new.html +17 -0
- hermitcrm/templates/contact.html +70 -0
- hermitcrm/templates/contact_new.html +60 -0
- hermitcrm/templates/contacts.html +28 -0
- hermitcrm/templates/disclaimer_modal.html +31 -0
- hermitcrm/templates/enrich_preview.html +37 -0
- hermitcrm/templates/extension.html +43 -0
- hermitcrm/templates/feedback.html +38 -0
- hermitcrm/templates/help.html +17 -0
- hermitcrm/templates/home.html +93 -0
- hermitcrm/templates/icons.html +20 -0
- hermitcrm/templates/import.html +45 -0
- hermitcrm/templates/import_preview.html +54 -0
- hermitcrm/templates/interaction_edit.html +11 -0
- hermitcrm/templates/interaction_new.html +7 -0
- hermitcrm/templates/macros.html +396 -0
- hermitcrm/templates/merge.html +23 -0
- hermitcrm/templates/messages.html +44 -0
- hermitcrm/templates/report_rows.html +26 -0
- hermitcrm/templates/reports.html +124 -0
- hermitcrm/templates/sample_remove.html +24 -0
- hermitcrm/templates/settings.html +392 -0
- hermitcrm/templates/tasks.html +53 -0
- hermitcrm/templates/to_file.html +32 -0
- hermitcrm/templates/welcome.html +65 -0
- hermitcrm/updates.py +277 -0
- hermitcrm/usertheme.py +88 -0
- hermitcrm/web.py +3021 -0
- hermitcrm/welcome.py +128 -0
- hermitcrm-0.5.0.dist-info/METADATA +525 -0
- hermitcrm-0.5.0.dist-info/RECORD +104 -0
- hermitcrm-0.5.0.dist-info/WHEEL +5 -0
- hermitcrm-0.5.0.dist-info/entry_points.txt +2 -0
- hermitcrm-0.5.0.dist-info/licenses/LICENSE +202 -0
- hermitcrm-0.5.0.dist-info/licenses/NOTICE +35 -0
- 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)
|