@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.
@@ -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.
@@ -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 are now active — no further prompts.');
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.');
@@ -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 Stop
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 = "sess_%s.json" % sref if sref != "s" else "%d_anon.json" % int(time.time() * 1000)
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
- transcript = _redact(_read_transcript(event.get("transcript_path")))
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
- if transcript.strip():
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:
@@ -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.2.0",
4
- "description": "One-command frictionless onboarding for Xmarts Genius (Brain MCP + per-turn injection + automatic session capture).",
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": {