@melaya/runner 1.0.97 → 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.
- package/dist/assistantHost.py +115 -1
- package/dist/connection.js +65 -4
- package/dist/modelLoader.js +21 -0
- package/package.json +42 -42
package/dist/assistantHost.py
CHANGED
|
@@ -37,6 +37,74 @@ import traceback
|
|
|
37
37
|
_EVENT_PREFIX = "MELASSIST "
|
|
38
38
|
_IDLE_EXIT_SECONDS = int(os.environ.get("MEL_ASSISTANT_IDLE_SECONDS", "900")) # 15 min
|
|
39
39
|
|
|
40
|
+
# PR4 rehydration: this host instance id + the DB generation it booted under.
|
|
41
|
+
import uuid as _uuid
|
|
42
|
+
_HOST_ID = _uuid.uuid4().hex
|
|
43
|
+
try:
|
|
44
|
+
_GENERATION = int(os.environ.get("MEL_ASSISTANT_GENERATION", "0") or 0)
|
|
45
|
+
except Exception:
|
|
46
|
+
_GENERATION = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_async(coro):
|
|
50
|
+
"""Run a coroutine to completion from the synchronous main loop (no running
|
|
51
|
+
loop here — turns use their own asyncio.run)."""
|
|
52
|
+
import asyncio
|
|
53
|
+
return asyncio.run(coro)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _agent_memory(agent):
|
|
57
|
+
"""Best-effort handle to the agent's BoundedMemory (for watermark + restore)."""
|
|
58
|
+
try:
|
|
59
|
+
mem = getattr(agent, "memory", None)
|
|
60
|
+
if mem is not None and hasattr(mem, "memory_watermark"):
|
|
61
|
+
return mem
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _memory_watermark(agent) -> int:
|
|
68
|
+
mem = _agent_memory(agent)
|
|
69
|
+
try:
|
|
70
|
+
return int(mem.memory_watermark()) if mem is not None else 0
|
|
71
|
+
except Exception:
|
|
72
|
+
return 0
|
|
73
|
+
|
|
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
|
+
|
|
97
|
+
def _render_summary_text(summary) -> str:
|
|
98
|
+
"""Render a server AssistantSummary ({facts, provenance}) to a data-only text
|
|
99
|
+
block for the compressed-summary slot. Never policy — reference only."""
|
|
100
|
+
if not isinstance(summary, dict):
|
|
101
|
+
return ""
|
|
102
|
+
facts = summary.get("facts") or []
|
|
103
|
+
lines = [f"- {str(f)[:240]}" for f in facts if str(f).strip()]
|
|
104
|
+
if not lines:
|
|
105
|
+
return ""
|
|
106
|
+
return "[Earlier conversation summary — reference only]\n" + "\n".join(lines)
|
|
107
|
+
|
|
40
108
|
# Streaming state for the CURRENT turn — the pre_print / post_acting hooks read
|
|
41
109
|
# this to emit delta / tool events keyed to the turn in flight. `cancel` is the
|
|
42
110
|
# STOP flag: set by the stdin-reader thread, polled by the running turn.
|
|
@@ -502,7 +570,20 @@ def main() -> int:
|
|
|
502
570
|
_emit("", "error", message=f"assistant host failed to start: {exc}")
|
|
503
571
|
return 1
|
|
504
572
|
|
|
505
|
-
|
|
573
|
+
# PR4: report memory watermark + host identity + generation so the server can
|
|
574
|
+
# decide whether to offer a generation-bound rehydrate snapshot.
|
|
575
|
+
# Surface context compaction to the chat UI on the RUNNER path too: the
|
|
576
|
+
# BoundedMemory fires pre_compact / post_compact around a summarise pass.
|
|
577
|
+
try:
|
|
578
|
+
from shared.orchestration.hooks import pipeline_hooks
|
|
579
|
+
pipeline_hooks.register("pre_compact", lambda ctx: _emit(str(_stream.get("turnId") or ""), "compacting"))
|
|
580
|
+
pipeline_hooks.register("post_compact", lambda ctx: _emit(
|
|
581
|
+
str(_stream.get("turnId") or ""), "compacted",
|
|
582
|
+
dropped=int((ctx or {}).get("messages_compressed") or 0)))
|
|
583
|
+
except Exception:
|
|
584
|
+
_log("compaction hooks unavailable (non-fatal)")
|
|
585
|
+
|
|
586
|
+
_emit("", "ready", memoryWatermark=_memory_watermark(agent), hostInstanceId=_HOST_ID, generation=_GENERATION, configHash=_config_hash())
|
|
506
587
|
_log("ready")
|
|
507
588
|
|
|
508
589
|
q: "queue.Queue[str]" = queue.Queue()
|
|
@@ -525,6 +606,39 @@ def main() -> int:
|
|
|
525
606
|
except Exception:
|
|
526
607
|
_log(f"bad stdin line (not json): {line[:120]}")
|
|
527
608
|
continue
|
|
609
|
+
# PR4: generation-bound rehydrate. Seed a FRESH host's memory from the
|
|
610
|
+
# server snapshot (task pin + summary + recent tail) via restoreSnapshot —
|
|
611
|
+
# watermark-guarded so a re-offer never double-seeds. Never memory.add().
|
|
612
|
+
if isinstance(req, dict) and req.get("kind") == "restore":
|
|
613
|
+
try:
|
|
614
|
+
gen = int(req.get("generation") or 0)
|
|
615
|
+
except Exception:
|
|
616
|
+
gen = 0
|
|
617
|
+
# Strict: gen 0 is a real generation (pre-migration), not a wildcard.
|
|
618
|
+
if gen != _GENERATION:
|
|
619
|
+
_log(f"restore for stale generation {gen} (mine={_GENERATION}) — ignored")
|
|
620
|
+
continue
|
|
621
|
+
mem = _agent_memory(agent)
|
|
622
|
+
if mem is None:
|
|
623
|
+
continue
|
|
624
|
+
try:
|
|
625
|
+
from agentscope.message import Msg as _Msg
|
|
626
|
+
recent = req.get("recent") or []
|
|
627
|
+
recent_msgs = [
|
|
628
|
+
_Msg(str(m.get("role") or "user"), str(m.get("content") or ""), str(m.get("role") or "user"))
|
|
629
|
+
for m in recent if isinstance(m, dict) and str(m.get("content") or "").strip()
|
|
630
|
+
]
|
|
631
|
+
applied = _run_async(mem.restore_snapshot(
|
|
632
|
+
task_pin=str(req.get("taskPin") or ""),
|
|
633
|
+
summary_text=_render_summary_text(req.get("summary")),
|
|
634
|
+
recent_msgs=recent_msgs,
|
|
635
|
+
watermark=int(req.get("watermark") or 0),
|
|
636
|
+
))
|
|
637
|
+
_emit("", "rehydrated", applied=bool(applied), watermark=_memory_watermark(agent))
|
|
638
|
+
_log(f"restore applied={applied} watermark={_memory_watermark(agent)}")
|
|
639
|
+
except Exception:
|
|
640
|
+
_log("restore failed:\n" + traceback.format_exc())
|
|
641
|
+
continue
|
|
528
642
|
turn_id = str(req.get("turnId") or "")
|
|
529
643
|
message = str(req.get("message") or "")
|
|
530
644
|
# Per-turn autonomy mode: the TS side carries an updated `hitl_mode`
|
package/dist/connection.js
CHANGED
|
@@ -29,6 +29,13 @@ import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
|
|
|
29
29
|
const HEARTBEAT_INTERVAL = 30_000;
|
|
30
30
|
const activeProcesses = new Map();
|
|
31
31
|
const activeAssistants = new Map();
|
|
32
|
+
// PR4: sid-keyed boot reservation. The guard-then-spawn in runner:assistant_start
|
|
33
|
+
// has an async gap (ensureSharedModules / ensurePythonEnv awaits) between the
|
|
34
|
+
// activeAssistants.has() check and the .set(). A second assistant_start in that
|
|
35
|
+
// window — a concurrent SSE, or a RESTARTED server re-emitting start while the
|
|
36
|
+
// host is still booting — would double-spawn two python hosts. Reserving the sid
|
|
37
|
+
// SYNCHRONOUSLY here closes that window.
|
|
38
|
+
const bootingAssistants = new Set();
|
|
32
39
|
const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
|
|
33
40
|
export async function connect(opts) {
|
|
34
41
|
const spinner = ora("Connecting to Melaya...").start();
|
|
@@ -876,11 +883,21 @@ export async function connect(opts) {
|
|
|
876
883
|
const sid = String(payload.sessionId || "");
|
|
877
884
|
if (!sid)
|
|
878
885
|
return;
|
|
886
|
+
const generation = Number(payload.generation ?? 0) || 0;
|
|
879
887
|
const emitEv = (ev) => socket.emit("runner:assistant_event", { sessionId: sid, turnId: "", ...ev });
|
|
880
|
-
|
|
881
|
-
|
|
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 });
|
|
882
894
|
return;
|
|
883
895
|
}
|
|
896
|
+
// A boot is already in flight for this sid (concurrent start / server restart
|
|
897
|
+
// mid-boot). Drop this duplicate — the in-flight boot will emit `ready`.
|
|
898
|
+
if (bootingAssistants.has(sid))
|
|
899
|
+
return;
|
|
900
|
+
bootingAssistants.add(sid); // SYNCHRONOUS reservation before any await
|
|
884
901
|
try {
|
|
885
902
|
const sharedVersion = String(payload.sharedVersion ?? "latest");
|
|
886
903
|
await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
|
|
@@ -946,6 +963,10 @@ export async function connect(opts) {
|
|
|
946
963
|
// reads this at start; each turn's frame can flip it (see assistant_turn).
|
|
947
964
|
// Fail-safe: unknown ⇒ "safe".
|
|
948
965
|
MEL_ASSISTANT_HITL_MODE: (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe",
|
|
966
|
+
// PR4: the DB-authoritative generation this host boots under. The host
|
|
967
|
+
// echoes it in `ready` and stamps events; an older boot self-terminates
|
|
968
|
+
// on a generation mismatch.
|
|
969
|
+
MEL_ASSISTANT_GENERATION: String(generation),
|
|
949
970
|
// Run context for phone-driving assistant turns. The assistant host has
|
|
950
971
|
// no pipeline MEL_RUN_ID, so without this phone.py._cmd omits `run_id`,
|
|
951
972
|
// the server's arm block (phone.ts, `if (runId)`) never fires, and the
|
|
@@ -968,7 +989,7 @@ export async function connect(opts) {
|
|
|
968
989
|
...(payload.credentials || {}),
|
|
969
990
|
};
|
|
970
991
|
const proc = spawn(envResult.pythonPath, ["-u", stagedHost], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
971
|
-
const session = { proc, lastActivity: Date.now(), stdoutBuf: "" };
|
|
992
|
+
const session = { proc, lastActivity: Date.now(), stdoutBuf: "", generation, memoryWatermark: 0 };
|
|
972
993
|
activeAssistants.set(sid, session);
|
|
973
994
|
console.log(chalk.hex("#7c6ff0")(` ◆ Assistant session ${sid.slice(0, 10)}… (${payload.provider})`));
|
|
974
995
|
proc.stdout?.on("data", (data) => {
|
|
@@ -980,7 +1001,13 @@ export async function connect(opts) {
|
|
|
980
1001
|
const t = line.trim();
|
|
981
1002
|
if (t.startsWith("MELASSIST ")) {
|
|
982
1003
|
try {
|
|
983
|
-
|
|
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 });
|
|
984
1011
|
}
|
|
985
1012
|
catch { /* skip malformed */ }
|
|
986
1013
|
}
|
|
@@ -1012,6 +1039,40 @@ export async function connect(opts) {
|
|
|
1012
1039
|
catch (e) {
|
|
1013
1040
|
emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
|
|
1014
1041
|
}
|
|
1042
|
+
finally {
|
|
1043
|
+
// Release the boot reservation on EVERY path (spawned, or errored/returned
|
|
1044
|
+
// early). By now the host is either in activeAssistants or dead.
|
|
1045
|
+
bootingAssistants.delete(sid);
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
// PR4: generation-bound rehydrate. The server sends this AFTER `ready`, only
|
|
1049
|
+
// when the DB watermark is newer than the host's. Forward it to the host as a
|
|
1050
|
+
// `restore` frame; the host applies it via restoreSnapshot ONLY if its own
|
|
1051
|
+
// memory watermark is older (idempotent — a re-offer never double-seeds).
|
|
1052
|
+
socket.on("runner:assistant_rehydrate", (payload) => {
|
|
1053
|
+
const sid = String(payload.sessionId || "");
|
|
1054
|
+
const s = activeAssistants.get(sid);
|
|
1055
|
+
if (!s)
|
|
1056
|
+
return;
|
|
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.
|
|
1060
|
+
const gen = Number(payload.generation ?? 0) || 0;
|
|
1061
|
+
if (gen !== s.generation)
|
|
1062
|
+
return;
|
|
1063
|
+
s.lastActivity = Date.now();
|
|
1064
|
+
try {
|
|
1065
|
+
s.proc.stdin?.write(JSON.stringify({
|
|
1066
|
+
kind: "restore",
|
|
1067
|
+
generation: gen,
|
|
1068
|
+
watermark: Number(payload.watermark ?? 0) || 0,
|
|
1069
|
+
taskPin: payload.taskPin ?? null,
|
|
1070
|
+
summary: payload.summary ?? null,
|
|
1071
|
+
summaryUptoSeq: Number(payload.summaryUptoSeq ?? 0) || 0,
|
|
1072
|
+
recent: Array.isArray(payload.recent) ? payload.recent : [],
|
|
1073
|
+
}) + "\n");
|
|
1074
|
+
}
|
|
1075
|
+
catch { /* best-effort — host stays as-is on a write failure */ }
|
|
1015
1076
|
});
|
|
1016
1077
|
socket.on("runner:assistant_turn", (payload) => {
|
|
1017
1078
|
const s = activeAssistants.get(String(payload.sessionId || ""));
|
package/dist/modelLoader.js
CHANGED
|
@@ -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,42 +1,42 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@melaya/runner",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"private": false,
|
|
7
|
-
"type": "module",
|
|
8
|
-
"bin": {
|
|
9
|
-
"melaya-runner": "dist/cli.js"
|
|
10
|
-
},
|
|
11
|
-
"main": "dist/index.js",
|
|
12
|
-
"files": [
|
|
13
|
-
"dist/**/*.js",
|
|
14
|
-
"dist/**/*.d.ts",
|
|
15
|
-
"dist/**/*.py",
|
|
16
|
-
"localRagIngest.py",
|
|
17
|
-
"localRagRetrieve.py",
|
|
18
|
-
"nltk_data/**",
|
|
19
|
-
"README.md"
|
|
20
|
-
],
|
|
21
|
-
"scripts": {
|
|
22
|
-
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
|
-
"prepublishOnly": "npm run build"
|
|
24
|
-
},
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"chalk": "^5.3.0",
|
|
27
|
-
"commander": "^12.0.0",
|
|
28
|
-
"ora": "^8.0.0",
|
|
29
|
-
"playwright": "^1.47.0",
|
|
30
|
-
"socket.io-client": "^4.8.0"
|
|
31
|
-
},
|
|
32
|
-
"devDependencies": {
|
|
33
|
-
"@types/node": "^20.0.0",
|
|
34
|
-
"typescript": "^5.5.0"
|
|
35
|
-
},
|
|
36
|
-
"engines": {
|
|
37
|
-
"node": ">=18"
|
|
38
|
-
},
|
|
39
|
-
"publishConfig": {
|
|
40
|
-
"access": "public"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@melaya/runner",
|
|
3
|
+
"version": "1.0.100",
|
|
4
|
+
"description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"melaya-runner": "dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/**/*.js",
|
|
14
|
+
"dist/**/*.d.ts",
|
|
15
|
+
"dist/**/*.py",
|
|
16
|
+
"localRagIngest.py",
|
|
17
|
+
"localRagRetrieve.py",
|
|
18
|
+
"nltk_data/**",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"chalk": "^5.3.0",
|
|
27
|
+
"commander": "^12.0.0",
|
|
28
|
+
"ora": "^8.0.0",
|
|
29
|
+
"playwright": "^1.47.0",
|
|
30
|
+
"socket.io-client": "^4.8.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^20.0.0",
|
|
34
|
+
"typescript": "^5.5.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|