@xmarts/genius-setup 1.2.0 → 1.4.0
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.
- package/CLAUDE_PROTOCOL.md +36 -0
- package/bin/genius-setup.js +27 -1
- package/hooks/genius_capture.py +119 -9
- package/hooks/genius_inject.py +45 -0
- package/package.json +3 -2
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Xmarts Genius — Protocolo de Consultor
|
|
2
|
+
|
|
3
|
+
> Esta sección la instala/actualiza automáticamente `@xmarts/genius-setup`. No la edites a mano.
|
|
4
|
+
|
|
5
|
+
## Cómo funciona (cero fricción)
|
|
6
|
+
|
|
7
|
+
El Brain de Genius está conectado vía el MCP **`beesmart_genius`** y dos hooks de Claude Code:
|
|
8
|
+
- **Inyección por turno** (UserPromptSubmit/SessionStart): en cada turno recibes automáticamente los
|
|
9
|
+
learnings, playbooks y patrones relevantes — no tienes que pedirlos.
|
|
10
|
+
- **Captura automática** (SessionEnd/PreCompact/Stop): tus hallazgos y tu sesión se capturan solos al
|
|
11
|
+
final; no tienes que documentar manualmente.
|
|
12
|
+
- **Auto-actualización**: el paquete se refresca solo en segundo plano (1×/día). Cuando agregamos tools,
|
|
13
|
+
skills o features, las recibes en tus turnos **sin hacer nada**.
|
|
14
|
+
|
|
15
|
+
## Reglas Obligatorias (siempre activas)
|
|
16
|
+
|
|
17
|
+
1. **ANTES de cada tarea**: usa `query_knowledge` (o `ask_brain`) para buscar conocimiento existente.
|
|
18
|
+
2. **AL descubrir algo nuevo**: captura con `capture_learning` — título claro + resolución detallada.
|
|
19
|
+
3. **AL resolver un error**: documenta error → fix (el Brain auto-detecta el par).
|
|
20
|
+
4. **SIEMPRE usa `target_client`** para operar en un cliente (p.ej. `target_client="GEA Ecuador"`).
|
|
21
|
+
5. **NUNCA te conectes directo al Odoo del cliente** — todo fluye por el MCP `beesmart_genius`.
|
|
22
|
+
6. Tu tiempo se sugiere solo a partir de tu actividad; confírmalo cuando puedas en **My Week**
|
|
23
|
+
(`/mcp/webapp/my-week`). Solo el tiempo que confirmas cuenta como horas reales.
|
|
24
|
+
|
|
25
|
+
## Tools (servidas dinámicamente por `beesmart_genius`)
|
|
26
|
+
|
|
27
|
+
| Tool | Cuándo usar |
|
|
28
|
+
|------|-------------|
|
|
29
|
+
| `query_knowledge` | Antes de cada tarea — busca en el Brain |
|
|
30
|
+
| `capture_learning` | Al descubrir algo nuevo |
|
|
31
|
+
| `get_my_profile` | Ver tu perfil de consultor |
|
|
32
|
+
| `search_read` / `execute_orm` + `target_client` | Consultar/operar data de cualquier cliente vía gateway |
|
|
33
|
+
| `list_models` / `get_model_schema` + `target_client` | Inspeccionar esquema de un cliente |
|
|
34
|
+
|
|
35
|
+
> La lista completa de tools (y skills como prompts) la sirve el MCP en tiempo real; al agregar nuevas,
|
|
36
|
+
> aparecen automáticamente en tu sesión sin reconfigurar nada.
|
package/bin/genius-setup.js
CHANGED
|
@@ -118,12 +118,38 @@ function addHook(eventName, scriptPath) {
|
|
|
118
118
|
['UserPromptSubmit', 'SessionStart'].forEach((e) => addHook(e, injectDst));
|
|
119
119
|
writeJson(settingsPath, settings);
|
|
120
120
|
|
|
121
|
+
// 4) ~/.claude/CLAUDE.md — install/refresh the Genius Protocol between auto-managed
|
|
122
|
+
// markers (idempotent; never clobbers the consultant's surrounding content).
|
|
123
|
+
const BEGIN = '<!-- XMARTS-GENIUS-PROTOCOL:BEGIN (auto-managed by @xmarts/genius-setup) -->';
|
|
124
|
+
const END = '<!-- XMARTS-GENIUS-PROTOCOL:END -->';
|
|
125
|
+
try {
|
|
126
|
+
ensureDir(CLAUDE_DIR);
|
|
127
|
+
const claudeMdPath = path.join(CLAUDE_DIR, 'CLAUDE.md');
|
|
128
|
+
let existing = '';
|
|
129
|
+
try { existing = fs.readFileSync(claudeMdPath, 'utf8'); } catch (_) {}
|
|
130
|
+
const proto = fs.readFileSync(path.join(__dirname, '..', 'CLAUDE_PROTOCOL.md'), 'utf8').trim();
|
|
131
|
+
const block = BEGIN + '\n' + proto + '\n' + END;
|
|
132
|
+
let out;
|
|
133
|
+
if (existing.includes(BEGIN) && existing.includes(END)) {
|
|
134
|
+
out = existing.slice(0, existing.indexOf(BEGIN)) + block +
|
|
135
|
+
existing.slice(existing.indexOf(END) + END.length);
|
|
136
|
+
} else {
|
|
137
|
+
out = (existing ? existing.replace(/\s+$/, '') + '\n\n' : '') + block + '\n';
|
|
138
|
+
}
|
|
139
|
+
fs.writeFileSync(claudeMdPath, out);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
log('WARN: could not write CLAUDE.md protocol (' + e.message + ')');
|
|
142
|
+
}
|
|
143
|
+
|
|
121
144
|
log('');
|
|
122
145
|
log(' Xmarts Genius is set up.');
|
|
123
146
|
log(' - MCP server : beesmart_genius -> ' + BASE + '/mcp/consultant');
|
|
124
147
|
log(' - Capture hook : ' + hookDst + ' (SessionEnd / PreCompact / Stop)');
|
|
125
148
|
log(' - Inject hook : ' + injectDst + ' (UserPromptSubmit / SessionStart)');
|
|
126
149
|
log(' - Config : ' + path.join(GENIUS_DIR, 'config.json'));
|
|
150
|
+
log(' - Protocol : ' + path.join(CLAUDE_DIR, 'CLAUDE.md') + ' (auto-managed)');
|
|
127
151
|
if (CLIENT) log(' - Default client : ' + CLIENT);
|
|
128
152
|
log('');
|
|
129
|
-
log(' Restart Claude Code. Per-turn Brain injection + automatic capture
|
|
153
|
+
log(' Restart Claude Code. Per-turn Brain injection + automatic capture + the Genius');
|
|
154
|
+
log(' protocol are now active. The package SELF-UPDATES daily in the background, so new');
|
|
155
|
+
log(' tools, skills and features arrive automatically — you never have to run this again.');
|
package/hooks/genius_capture.py
CHANGED
|
@@ -18,6 +18,7 @@ Hard rules:
|
|
|
18
18
|
Input: Claude Code passes a JSON event on stdin including `transcript_path` and
|
|
19
19
|
`session_id`. We tolerate missing fields.
|
|
20
20
|
"""
|
|
21
|
+
import datetime as _dt
|
|
21
22
|
import json
|
|
22
23
|
import os
|
|
23
24
|
import re
|
|
@@ -63,6 +64,75 @@ def _read_transcript(path):
|
|
|
63
64
|
return ""
|
|
64
65
|
|
|
65
66
|
|
|
67
|
+
_SESSION_GAP_SECONDS = 30 * 60 # idle gap that splits work into blocks
|
|
68
|
+
_METRIC_MAX_LINES = 40000
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_ts(s):
|
|
72
|
+
try:
|
|
73
|
+
return _dt.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _session_metrics(path):
|
|
79
|
+
"""Derive honest session timing from the transcript JSONL: gap-clustered
|
|
80
|
+
ACTIVE minutes (idle gaps removed), start/end, and tool-event count. This is
|
|
81
|
+
the human-work signal the server turns into hours — computed locally so no
|
|
82
|
+
transcript content is needed to know the TIME spent."""
|
|
83
|
+
if not path or not os.path.isfile(path):
|
|
84
|
+
return None
|
|
85
|
+
times, tool_events = [], 0
|
|
86
|
+
try:
|
|
87
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
88
|
+
for i, line in enumerate(fh):
|
|
89
|
+
if i >= _METRIC_MAX_LINES:
|
|
90
|
+
break
|
|
91
|
+
line = line.strip()
|
|
92
|
+
if not line:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
obj = json.loads(line)
|
|
96
|
+
except Exception:
|
|
97
|
+
continue
|
|
98
|
+
t = _parse_ts(obj.get("timestamp")) if obj.get("timestamp") else None
|
|
99
|
+
if t:
|
|
100
|
+
times.append(t)
|
|
101
|
+
msg = obj.get("message") or {}
|
|
102
|
+
content = msg.get("content") if isinstance(msg, dict) else None
|
|
103
|
+
if isinstance(content, list):
|
|
104
|
+
for blk in content:
|
|
105
|
+
if isinstance(blk, dict) and blk.get("type") == "tool_use":
|
|
106
|
+
tool_events += 1
|
|
107
|
+
except Exception:
|
|
108
|
+
return None
|
|
109
|
+
if not times:
|
|
110
|
+
return None
|
|
111
|
+
times.sort()
|
|
112
|
+
active = 0.0
|
|
113
|
+
start = prev = times[0]
|
|
114
|
+
blocks = []
|
|
115
|
+
for t in times[1:]:
|
|
116
|
+
if (t - prev).total_seconds() > _SESSION_GAP_SECONDS:
|
|
117
|
+
blocks.append((start, prev))
|
|
118
|
+
start = t
|
|
119
|
+
prev = t
|
|
120
|
+
blocks.append((start, prev))
|
|
121
|
+
for a, b in blocks:
|
|
122
|
+
active += max((b - a).total_seconds(), 60.0) # >=1 min per block
|
|
123
|
+
|
|
124
|
+
def _fmt(d):
|
|
125
|
+
if d.tzinfo:
|
|
126
|
+
d = d.astimezone(_dt.timezone.utc).replace(tzinfo=None)
|
|
127
|
+
return d.strftime("%Y-%m-%d %H:%M:%S")
|
|
128
|
+
return {
|
|
129
|
+
"started_at": _fmt(times[0]),
|
|
130
|
+
"ended_at": _fmt(times[-1]),
|
|
131
|
+
"active_minutes": round(active / 60.0, 2),
|
|
132
|
+
"tool_events": tool_events,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
66
136
|
_CRED_PATTERNS = [
|
|
67
137
|
re.compile(r'-----BEGIN[A-Z ]*PRIVATE KEY-----.*?-----END[A-Z ]*PRIVATE KEY-----', re.DOTALL),
|
|
68
138
|
re.compile(r'(?i)\bBearer\s+[A-Za-z0-9._\-]{20,}'),
|
|
@@ -95,15 +165,16 @@ def _spool_files():
|
|
|
95
165
|
return sorted(f for f in os.listdir(OUTBOX_DIR) if f.endswith(".json"))
|
|
96
166
|
|
|
97
167
|
|
|
98
|
-
def _spool(payload):
|
|
99
|
-
"""Persist a payload. (#pkg-2) One file per session_ref — repeated
|
|
168
|
+
def _spool(payload, prefix="sess"):
|
|
169
|
+
"""Persist a payload. (#pkg-2) One file per (prefix, session_ref) — repeated
|
|
100
170
|
firings of the SAME session OVERWRITE one file instead of creating N — and a
|
|
101
171
|
true on-disk RING BUFFER cap, dropping the oldest when at MAX_OUTBOX_FILES,
|
|
102
|
-
so the directory can never grow without bound.
|
|
172
|
+
so the directory can never grow without bound. The prefix keeps the transcript
|
|
173
|
+
payload ('sess') and the session-metadata payload ('meta') as distinct files."""
|
|
103
174
|
os.makedirs(OUTBOX_DIR, exist_ok=True)
|
|
104
175
|
sref = (payload.get("session_ref") or "s")[:40].replace("/", "_").replace(".", "_")
|
|
105
|
-
# session dedup: a stable name per session -> overwrite, not append
|
|
106
|
-
name = "
|
|
176
|
+
# session dedup: a stable name per (prefix, session) -> overwrite, not append
|
|
177
|
+
name = "%s_%s.json" % (prefix, sref) if sref != "s" else "%d_anon.json" % int(time.time() * 1000)
|
|
107
178
|
path = os.path.join(OUTBOX_DIR, name)
|
|
108
179
|
if not os.path.exists(path):
|
|
109
180
|
existing = _spool_files()
|
|
@@ -122,6 +193,7 @@ def _spool(payload):
|
|
|
122
193
|
|
|
123
194
|
|
|
124
195
|
def _post(endpoint, token, payload, timeout=15):
|
|
196
|
+
payload = {k: v for k, v in payload.items() if k != "_endpoint"}
|
|
125
197
|
body = json.dumps({"jsonrpc": "2.0", "method": "call",
|
|
126
198
|
"params": payload, "id": 1}).encode()
|
|
127
199
|
req = urllib.request.Request(endpoint, data=body)
|
|
@@ -154,7 +226,7 @@ def _flush_outbox(endpoint, token):
|
|
|
154
226
|
try:
|
|
155
227
|
with open(path) as fh:
|
|
156
228
|
payload = json.load(fh)
|
|
157
|
-
_post(endpoint, token, payload)
|
|
229
|
+
_post(payload.get("_endpoint") or endpoint, token, payload)
|
|
158
230
|
os.remove(path) # delivered
|
|
159
231
|
except PermanentError:
|
|
160
232
|
_deadletter(path) # bad payload -> set aside, keep going
|
|
@@ -186,20 +258,58 @@ def main():
|
|
|
186
258
|
if not endpoint or not token:
|
|
187
259
|
return # not configured -> silent no-op
|
|
188
260
|
|
|
261
|
+
# Derive the metadata-only session endpoint (no content) from the configured
|
|
262
|
+
# capture endpoint or base_url.
|
|
263
|
+
session_endpoint = cfg.get("session_endpoint")
|
|
264
|
+
if not session_endpoint:
|
|
265
|
+
if "/capture/transcript" in endpoint:
|
|
266
|
+
session_endpoint = endpoint.replace("/capture/transcript", "/capture/session")
|
|
267
|
+
elif cfg.get("base_url"):
|
|
268
|
+
session_endpoint = cfg["base_url"].rstrip("/") + "/xma/genius/v1/capture/session"
|
|
269
|
+
|
|
189
270
|
event = _read_stdin_event()
|
|
190
|
-
|
|
271
|
+
tpath = event.get("transcript_path")
|
|
272
|
+
transcript = _redact(_read_transcript(tpath))
|
|
191
273
|
session_ref = event.get("session_id") or event.get("session_ref") or ""
|
|
192
274
|
|
|
193
|
-
|
|
275
|
+
# Off-platform / sensitive work keeps CONTENT out of the Brain entirely — only
|
|
276
|
+
# the TIME (session metadata) is sent. Inferred from an explicit pii_safe flag
|
|
277
|
+
# or a topic set with no Genius client (e.g. Ateneai insurance/medical work).
|
|
278
|
+
offplatform = bool(cfg.get("pii_safe")) or (
|
|
279
|
+
bool(cfg.get("topic")) and not cfg.get("target_client"))
|
|
280
|
+
|
|
281
|
+
if transcript.strip() and not offplatform:
|
|
194
282
|
payload = {
|
|
195
283
|
"transcript": transcript,
|
|
196
284
|
"session_ref": session_ref,
|
|
197
285
|
"source": "session_end_hook",
|
|
286
|
+
"_endpoint": endpoint,
|
|
198
287
|
}
|
|
199
288
|
if cfg.get("target_client"):
|
|
200
289
|
payload["target_client"] = cfg["target_client"]
|
|
201
290
|
# persist first (durability), then try to deliver
|
|
202
|
-
_spool(payload)
|
|
291
|
+
_spool(payload, prefix="sess")
|
|
292
|
+
|
|
293
|
+
# Claude-session TIME metadata (no content) -> /capture/session. This is the
|
|
294
|
+
# honest human-work signal; it also covers off-platform sessions (no client).
|
|
295
|
+
if session_endpoint and session_ref:
|
|
296
|
+
metrics = _session_metrics(tpath)
|
|
297
|
+
if metrics and metrics.get("active_minutes", 0) > 0:
|
|
298
|
+
meta = {
|
|
299
|
+
"session_ref": session_ref,
|
|
300
|
+
"source": "transcript_hook",
|
|
301
|
+
"is_offplatform": offplatform,
|
|
302
|
+
"_endpoint": session_endpoint,
|
|
303
|
+
}
|
|
304
|
+
meta.update(metrics)
|
|
305
|
+
if cfg.get("target_client"):
|
|
306
|
+
meta["target_client"] = cfg["target_client"]
|
|
307
|
+
ws = cfg.get("workspace") or event.get("cwd")
|
|
308
|
+
if ws:
|
|
309
|
+
meta["workspace"] = ws
|
|
310
|
+
if cfg.get("topic"):
|
|
311
|
+
meta["topic"] = cfg["topic"]
|
|
312
|
+
_spool(meta, prefix="meta")
|
|
203
313
|
|
|
204
314
|
# Always attempt to flush the spool (delivers this + any backlog)
|
|
205
315
|
try:
|
package/hooks/genius_inject.py
CHANGED
|
@@ -22,6 +22,8 @@ import urllib.request
|
|
|
22
22
|
HOME = os.path.expanduser("~")
|
|
23
23
|
CFG_PATH = os.path.join(HOME, ".genius", "config.json")
|
|
24
24
|
TIMEOUT = 3.5
|
|
25
|
+
SELF_UPDATE_TTL = 86400 # at most once/day
|
|
26
|
+
SELF_UPDATE_STAMP = os.path.join(HOME, ".genius", ".last_selfupdate")
|
|
25
27
|
|
|
26
28
|
SESSION_PRIMER = (
|
|
27
29
|
"[Xmarts Genius — Brain connected]\n"
|
|
@@ -77,10 +79,53 @@ def _inject(cfg, prompt):
|
|
|
77
79
|
return ""
|
|
78
80
|
|
|
79
81
|
|
|
82
|
+
def _maybe_self_update(cfg):
|
|
83
|
+
"""Keep the consultant on the LATEST Genius package with ZERO action: at most
|
|
84
|
+
once/day, silently re-run the installer in the background. New hooks, MCP
|
|
85
|
+
tools, settings and the CLAUDE.md protocol then flow automatically — the
|
|
86
|
+
consultant never re-onboards. Detached + fully guarded; never blocks a turn.
|
|
87
|
+
"""
|
|
88
|
+
import time
|
|
89
|
+
import subprocess
|
|
90
|
+
try:
|
|
91
|
+
token = cfg.get("token")
|
|
92
|
+
if not token:
|
|
93
|
+
return
|
|
94
|
+
if os.path.exists(SELF_UPDATE_STAMP) and (
|
|
95
|
+
time.time() - os.path.getmtime(SELF_UPDATE_STAMP)) < SELF_UPDATE_TTL:
|
|
96
|
+
return
|
|
97
|
+
# Stamp FIRST so a failing/slow npx never causes a retry storm.
|
|
98
|
+
try:
|
|
99
|
+
os.makedirs(os.path.dirname(SELF_UPDATE_STAMP), exist_ok=True)
|
|
100
|
+
with open(SELF_UPDATE_STAMP, "w") as fh:
|
|
101
|
+
fh.write(str(int(time.time())))
|
|
102
|
+
except Exception:
|
|
103
|
+
return
|
|
104
|
+
env = dict(os.environ)
|
|
105
|
+
env["GENIUS_TOKEN"] = token
|
|
106
|
+
base = cfg.get("base_url")
|
|
107
|
+
devnull = open(os.devnull, "w")
|
|
108
|
+
if os.name == "nt":
|
|
109
|
+
cmd = "npx -y @xmarts/genius-setup@latest"
|
|
110
|
+
if base:
|
|
111
|
+
cmd += ' --base-url "%s"' % base
|
|
112
|
+
subprocess.Popen(cmd, shell=True, env=env, stdout=devnull, stderr=devnull,
|
|
113
|
+
stdin=subprocess.DEVNULL, creationflags=0x00000008) # DETACHED_PROCESS
|
|
114
|
+
else:
|
|
115
|
+
args = ["npx", "-y", "@xmarts/genius-setup@latest"]
|
|
116
|
+
if base:
|
|
117
|
+
args += ["--base-url", base]
|
|
118
|
+
subprocess.Popen(args, env=env, stdout=devnull, stderr=devnull,
|
|
119
|
+
stdin=subprocess.DEVNULL, start_new_session=True)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
80
124
|
def main():
|
|
81
125
|
cfg = _cfg()
|
|
82
126
|
if not cfg.get("token"):
|
|
83
127
|
return # not configured -> silent no-op
|
|
128
|
+
_maybe_self_update(cfg) # background, daily, zero-action upgrades
|
|
84
129
|
event = _event()
|
|
85
130
|
name = event.get("hook_event_name") or (
|
|
86
131
|
"UserPromptSubmit" if event.get("prompt") else "SessionStart")
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmarts/genius-setup",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "One-command
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "One-command, self-updating onboarding for Xmarts Genius (Brain MCP + per-turn injection + automatic session/time capture + CLAUDE.md protocol). Installs once; self-updates daily so new tools/skills/features arrive with zero action.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"genius-setup": "bin/genius-setup.js"
|
|
7
7
|
},
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"bin/genius-setup.js",
|
|
10
10
|
"hooks/genius_inject.py",
|
|
11
11
|
"hooks/genius_capture.py",
|
|
12
|
+
"CLAUDE_PROTOCOL.md",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|