@melaya/runner 1.0.98 → 1.0.101

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.
@@ -72,6 +72,28 @@ def _memory_watermark(agent) -> int:
72
72
  return 0
73
73
 
74
74
 
75
+ def _config_hash() -> str:
76
+ """PR4 config-drift: a stable hash over the config env that determines host
77
+ behaviour, canonicalized IDENTICALLY to the server (runnerNamespace.ts
78
+ _assistantConfigHash): pipe-delimited provider|model|language|connectors|
79
+ hitl|phoneReady, language default 'en', connectors lowercased+sorted,
80
+ phoneReady 1/0. Reported in `ready` so the server can detect env divergence."""
81
+ import hashlib
82
+ raw_conn = os.environ.get("MEL_ASSISTANT_CONNECTORS", "") or ""
83
+ connectors = ",".join(sorted(c.lower() for c in raw_conn.split(",") if c.strip()))
84
+ hitl = os.environ.get("MEL_ASSISTANT_HITL_MODE", "safe") or "safe"
85
+ if hitl not in ("safe", "autonomous", "payments_only"):
86
+ hitl = "safe"
87
+ phone = "1" if (os.environ.get("MEL_ASSISTANT_PHONE_READY", "") or "") else "0"
88
+ canon = "|".join([
89
+ os.environ.get("MEL_ASSISTANT_PROVIDER", "") or "",
90
+ os.environ.get("MEL_ASSISTANT_MODEL", "") or "",
91
+ os.environ.get("MEL_ASSISTANT_LANGUAGE", "en") or "en",
92
+ connectors, hitl, phone,
93
+ ])
94
+ return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
95
+
96
+
75
97
  def _render_summary_text(summary) -> str:
76
98
  """Render a server AssistantSummary ({facts, provenance}) to a data-only text
77
99
  block for the compressed-summary slot. Never policy — reference only."""
@@ -561,7 +583,7 @@ def main() -> int:
561
583
  except Exception:
562
584
  _log("compaction hooks unavailable (non-fatal)")
563
585
 
564
- _emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION)
586
+ _emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
565
587
  _log("ready")
566
588
 
567
589
  q: "queue.Queue[str]" = queue.Queue()
@@ -592,7 +614,8 @@ def main() -> int:
592
614
  gen = int(req.get("generation") or 0)
593
615
  except Exception:
594
616
  gen = 0
595
- if _GENERATION and gen and gen != _GENERATION:
617
+ # Strict: gen 0 is a real generation (pre-migration), not a wildcard.
618
+ if gen != _GENERATION:
596
619
  _log(f"restore for stale generation {gen} (mine={_GENERATION}) — ignored")
597
620
  continue
598
621
  mem = _agent_memory(agent)
@@ -885,10 +885,16 @@ export async function connect(opts) {
885
885
  return;
886
886
  const generation = Number(payload.generation ?? 0) || 0;
887
887
  const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
888
- // A live host already exists — report ready (with its watermark so the server
889
- // can decide whether to re-offer a rehydrate). No second spawn.
890
- if (activeAssistants.has(sid)) {
891
- emitEv({ kind: "ready" });
888
+ // A live host already exists — report ready WITH its last-known memory
889
+ // watermark so the server doesn't needlessly re-offer a rehydrate (a bare
890
+ // ready would read as watermark 0). No second spawn.
891
+ const live = activeAssistants.get(sid);
892
+ // Report the live host's ACTUAL boot generation (not just its watermark) so a
893
+ // server that restarted (its in-memory session map wiped) can DETECT a host
894
+ // that booted under an older generation and reboot it instead of serving the
895
+ // next turn from stale memory/config. A bare ready hid this.
896
+ if (live) {
897
+ emitEv({ kind: "ready", memoryWatermark: live.memoryWatermark || 0, generation: live.generation });
892
898
  return;
893
899
  }
894
900
  // A boot is already in flight for this sid (concurrent start / server restart
@@ -987,7 +993,7 @@ export async function connect(opts) {
987
993
  ...(payload.credentials || {}),
988
994
  };
989
995
  const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
990
- const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation };
996
+ const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation, memoryWatermark: 0 };
991
997
  activeAssistants.set(sid, session);
992
998
  console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
993
999
  proc.stdout?.on("data", (data) => {
@@ -999,7 +1005,13 @@ export async function connect(opts) {
999
1005
  const t = line.trim();
1000
1006
  if (t.startsWith("MELASSIST ")) {
1001
1007
  try {
1002
- socket.emit("runner:assistant_event", { sessionId: sid, ...JSON.parse(t.slice("MELASSIST ".length)) });
1008
+ const parsed = JSON.parse(t.slice("MELASSIST ".length));
1009
+ // Track the host's latest memory watermark (ready / rehydrated
1010
+ // frames report it) so a re-`start` of the live host reports a
1011
+ // truthful watermark and the server skips a needless rehydrate.
1012
+ if (typeof parsed?.memoryWatermark === "number")
1013
+ session.memoryWatermark = parsed.memoryWatermark;
1014
+ socket.emit("runner:assistant_event", { sessionId: sid, ...parsed });
1003
1015
  }
1004
1016
  catch { /* skip malformed */ }
1005
1017
  }
@@ -1046,10 +1058,11 @@ export async function connect(opts) {
1046
1058
  const s = activeAssistants.get(sid);
1047
1059
  if (!s)
1048
1060
  return;
1049
- // Bind to the boot generation — a frame for an older/newer generation than the
1050
- // live host is dropped (the host also re-checks).
1061
+ // Bind to the boot generation — a frame whose generation differs from the
1062
+ // live host's is dropped (the host also re-checks). Strict equality: gen 0 is
1063
+ // a real generation (pre-migration conversations), not a wildcard.
1051
1064
  const gen = Number(payload.generation ?? 0) || 0;
1052
- if (gen && s.generation && gen !== s.generation)
1065
+ if (gen !== s.generation)
1053
1066
  return;
1054
1067
  s.lastActivity = Date.now();
1055
1068
  try {
@@ -1071,6 +1084,15 @@ export async function connect(opts) {
1071
1084
  socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
1072
1085
  return;
1073
1086
  }
1087
+ // Generation gate (defense-in-depth): refuse to serve a turn stamped with a
1088
+ // generation that doesn't match this host's boot generation — the server has
1089
+ // moved on (restart / takeover) and this host is stale. session_not_found
1090
+ // triggers the server's clean re-boot path. Backward-compatible: a turn with
1091
+ // no generation (older server) is served as before.
1092
+ if (payload.generation != null && Number(payload.generation) !== s.generation) {
1093
+ socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: "session_not_found" });
1094
+ return;
1095
+ }
1074
1096
  s.lastActivity = Date.now();
1075
1097
  // Per-turn hitl_mode: the host applies it to os.environ["MEL_ASSISTANT_HITL_MODE"]
1076
1098
  // (mirrored to MEL_HITL_MODE) BEFORE running the turn, so a mid-session flip
@@ -378,6 +378,27 @@ export async function preflightOllama(modelName, onProgress = () => { }) {
378
378
  };
379
379
  }
380
380
  const profile = classifyModel(modelName);
381
+ // PR5: read the REAL trained context via /api/show and expose it as
382
+ // contextTokens (clamped to the operator cap, mirroring the Python
383
+ // detect_ollama_context). connection.ts forwards this as
384
+ // MEL_MODEL_CONTEXT_TOKENS so the TS profile matches the Python-detected
385
+ // window (the Python side self-detects too, so this is belt-and-suspenders).
386
+ try {
387
+ const cap = Number(process.env.MEL_OLLAMA_MAX_CTX || 16384) || 16384;
388
+ const show = await fetchWithTimeout(`${OLLAMA_BASE}/api/show`, {
389
+ timeoutMs: 3_000, method: "POST", body: JSON.stringify({ model: modelName }),
390
+ headers: { "content-type": "application/json" },
391
+ });
392
+ if (show.ok) {
393
+ const sj = await show.json();
394
+ const info = sj.model_info || {};
395
+ const key = Object.keys(info).find((k) => k.endsWith(".context_length"));
396
+ const trained = key ? Number(info[key]) : 0;
397
+ if (trained > 0)
398
+ profile.contextTokens = Math.max(2048, Math.min(trained, cap));
399
+ }
400
+ }
401
+ catch { /* best-effort — Python self-detects if this is missing */ }
381
402
  if (profile.tier !== "agentic-capable") {
382
403
  onProgress(`⚠ ${modelName} classified ${profile.tier} (${profile.reason})`);
383
404
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.0.98",
3
+ "version": "1.0.101",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,