@melaya/runner 1.0.98 → 1.0.100

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,12 @@ 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
+ if (live) {
893
+ emitEv({ kind: "ready", memoryWatermark: live.memoryWatermark || 0 });
892
894
  return;
893
895
  }
894
896
  // A boot is already in flight for this sid (concurrent start / server restart
@@ -987,7 +989,7 @@ export async function connect(opts) {
987
989
  ...(payload.credentials || {}),
988
990
  };
989
991
  const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
990
- const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation };
992
+ const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation, memoryWatermark: 0 };
991
993
  activeAssistants.set(sid, session);
992
994
  console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
993
995
  proc.stdout?.on("data", (data) => {
@@ -999,7 +1001,13 @@ export async function connect(opts) {
999
1001
  const t = line.trim();
1000
1002
  if (t.startsWith("MELASSIST ")) {
1001
1003
  try {
1002
- socket.emit("runner:assistant_event", { sessionId: sid, ...JSON.parse(t.slice("MELASSIST ".length)) });
1004
+ const parsed = JSON.parse(t.slice("MELASSIST ".length));
1005
+ // Track the host's latest memory watermark (ready / rehydrated
1006
+ // frames report it) so a re-`start` of the live host reports a
1007
+ // truthful watermark and the server skips a needless rehydrate.
1008
+ if (typeof parsed?.memoryWatermark === "number")
1009
+ session.memoryWatermark = parsed.memoryWatermark;
1010
+ socket.emit("runner:assistant_event", { sessionId: sid, ...parsed });
1003
1011
  }
1004
1012
  catch { /* skip malformed */ }
1005
1013
  }
@@ -1046,10 +1054,11 @@ export async function connect(opts) {
1046
1054
  const s = activeAssistants.get(sid);
1047
1055
  if (!s)
1048
1056
  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).
1057
+ // Bind to the boot generation — a frame whose generation differs from the
1058
+ // live host's is dropped (the host also re-checks). Strict equality: gen 0 is
1059
+ // a real generation (pre-migration conversations), not a wildcard.
1051
1060
  const gen = Number(payload.generation ?? 0) || 0;
1052
- if (gen && s.generation && gen !== s.generation)
1061
+ if (gen !== s.generation)
1053
1062
  return;
1054
1063
  s.lastActivity = Date.now();
1055
1064
  try {
@@ -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.100",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,